Introducción Link to heading
En el desarrollo de aplicaciones modernas, especialmente en entornos donde la escalabilidad y rendimiento son esenciales, uno de los mayores desafíos es la gestión eficiente de los recursos de hardware.
En este artículo veremos los fundamentes del multithreading, los distintos problemas en los que podríamos incurrir y los enfoques que existen para manejarlo.
Antes que nada, es necesario tener claras ciertas definiciones.
Distinción entre núcleo, proceso, hilo y tarea Link to heading
Núcleo Link to heading
Core
Componente físico dentro de un procesador, capaz de ejecutar instrucciones de forma independiente. Teniendo los procesadores modernos comúnmente varios núcleos, lo que permite ejecutar paralelamente múltiples instrucciones en el mismo ciclo de reloj.
Un núcleo a su vez cuenta con múltiples unidades de ejecución (Execution Units), las cuales son las que en ultima instancia realmente ejecutan las instrucciones. Dentro de estas existen distintos tipos, encargadas de procesar instrucciones de diferente naturaleza. Como por ejemplo, ALU (Arithmetic Logic Unit), encargada de las sumas, restas, comparaciones y operaciones lógicas o FPU (Floating Point Unit) para operaciones con números en coma flotante.
Podríamos simplificar la arquitectura de un CPU de la siguiente manera
CPU
├── Core 0
│ ├── Instruction Fetch Unit
│ ├── Scheduler
│ ├── Execution Units (ALU, FPU, etc.)
│ ├── Register Files
│ ├── L1 Cache
│ │ └── Inst-Cache
│ │ └── Data-Cache
│ └── L2 Cache
├── Core 1
│ └── ...
├── Shared L3 Cache
│ ↓
└── Integrated Memory Controller (IMC)
↓
DDR Channels (RAM)

Pero no solo termina ahí, al día de hoy existen múltiples técnicas para mejorar la eficiencia de los procesadores. Que son importantes tener en cuenta, para entender las posibles consecuencias que pueden conllevar.
Arquitectura Superscalar Link to heading
Los procesadores modernos suelen tener una arquitectura Superscalar, lo que se refiere a la implementación de una forma de paralelismo llamada instruction-level parallelism, que es la capacidad de despachar y ejecutar más de una instrucción por ciclo de reloj dentro de un mismo núcleo, gracias a que posee múltiples unidades de ejecución (ALUs, FPUs, etc.) y un Scheduler capaz de emitir instrucciones en paralelo. Cuando distintas instrucciones son de distinta naturaleza y por lo tanto requieren ser procesadas en unidades de ejecución distintas, estas pueden paralelizarse en el mismo ciclo de reloj.
Sin embargo, esto está limitado a instrucciones dentro de un mismo hilo, y siempre que no existan dependencias entre ellas. Por lo que si el flujo del hilo tiene dependencias o “burbujas” (por ejemplo, esperas de memoria), las unidades no utilizadas quedan ociosas.
Segmentación de instrucciones Link to heading
Instruction pipelining
Un procesador con arquitectura superescalar también suele implementar este tipo de optimización, también siendo una técnica de instruction-level parallelism. Al igual que la arquitectura superescalar, la segmentación busca mantener la mayor parte del procesador ocupado ejecutando instrucciones, esto lo logra dividiendo las instrucciones en series de pasos secuenciales, pudiendo ejecutar distintos de estos pasos de múltiples instrucciones en paralelo.
Mientras la arquitectura superescalar se basa en ejecutar múltiples instrucciones en paralelo utilizando múltiples unidades de ejecución, la segmentación de instrucciones ejecuta múltiples instrucciones en la misma unidad de ejecución en paralelo dividiéndolas en distintas fases.

- IF = Instruction Fetch
- ID = Instruction Decode
- EX = Execute
- MEM = Memory access
- WB = Register write back
Out-of-Order Execution Link to heading
Podríamos resumir este paradigma como el reordenamiento de la ejecución de instrucciones basado en la disponibilidad de los recursos de ejecución, en lugar de mantener el orden de ejecución original. Esto tiene el mismo objetivo de utilizar las unidades de ejecución de una mejor manera, evitando lo mas posible los tiempos muertos.
Como veremos luego, esta técnica en particular puede afectarnos, de manera muchas veces imperceptible, y tenemos que tenerla en cuenta a la hora de diseñar nuestro código.
Simultaneous multithreading (SMT) Link to heading
Mientras las anteriores técnicas buscan ejecutar varias instrucciones de un mismo hilo al mismo tiempo, el SMT permite ejecutar instrucciones de varios hilos distintos en el mismo ciclo y en el mismo núcleo. Aprovechando así mucho mas todas las unidades de ejecución del núcleo, y evitando lo mas posible las unidades de ejecución ociosas.
Pero para esto, un core con SMT debe de tener duplicado el estado del hilo (registros de propósito general, punteros de instrucción, etc.) mientras comparten los recursos de ejecución físicos. Esto lo podríamos ver como una división virtual de un núcleo en dos, en Intel a esto se le llama “Hyper-Threading” mientras que AMD utiliza su nombre oficial “Simultaneous multithreading”.
Coloquialmente a la división de un núcleo con SMT se le llama “hilos”, podemos leer o escuchar el termino “procesador con N núcleos y N hilos”. Esto quiere decir que si tenemos 16 núcleos y 32 hilos, tenemos 16 núcleos con capacidad de SMT, o como el caso de las ultimas arquitecturas de intel si tenemos 24 núcleos y 32 hilos, posiblemente tenemos 8 núcleos con capacidad SMT y 16 sin ella.
Es muy importante no confundir el termino hilo cuando se habla de esta separación virtual de los núcleos con los hilos de ejecución, a los que nos referíamos anteriormente y los cuales definiremos a profundidad mas adelante.
Cuando se dice que un núcleo “tiene dos hilos”, en realidad se refiere a la capacidad de ejecutar dos hilos a la vez.
Proceso Link to heading
Process
Un proceso es una instancia en ejecución de un programa.
Cuando un proceso es creado, el sistema operativo crea su correspondiente Process Control Block (PCB), una estructura interna donde se guarda toda la información necesaria para gestionar el proceso, incluyendo:
- Process scheduling state: Estado del proceso, junto con información como puede ser la prioridad, el tiempo transcurrido desde que el proceso tomo control del CPU, etc.
- Process structuring information: Los identificadores de los procesos hijos o los identificadores de otros procesos relacionados funcionalmente con el actual, que pueden representarse como una cola, un anillo u otras estructuras de datos.
- Interprocess communication information: información asociada con la comunicación entre procesos independientes.
- Process Privileges: Privilegios de acceso sobre recursos del sistema.
- Process State (new, ready, running, waiting, dead)
- Process Number (PID): Identificador único del proceso, también conocido como Process ID.
- Program Counter (PC): Un puntero a la siguiente instruccion a ejecutar.
- Registro del CPU
- Información de planificación de tiempos del CPU
- Información de manejo de memoria
- Accounting Information: Cantidad de CPU utilizada por el proceso, limites de tiempo, execution ID, etc.
- Informacion de estado de Lectura/Escritura (I/O): Lista de dispositivos I/O asignados al proceso.
Un proceso a su vez puede contener uno o varios hilos, los cuales compartirían su memoria y recursos.
Hilo Link to heading
Thread
Un hilo es una unidad básica de ejecución dentro de un proceso.
Mientras un proceso define el entorno (memoria, recursos, permisos), los hilos representan las “tareas” que se ejecutan dentro de ese entorno compartido.
Podríamos decir que un hilo está compuesto por:
-
Registros del CPU: Incluye el program counter (PC) (puntero de instrucción), registros generales, de segmento, flags, etc.
→ Esto permite pausar y reanudar un hilo exactamente en el punto donde estaba.
-
Stack (pila): Cada hilo tiene su propio stack para variables locales, llamadas a funciones, y almacenamiento de contexto de retorno.
→ Esto es lo que evita interferencia entre hilos al ejecutar funciones recursivas o con variables automáticas.
-
Thread Control Block (TCB): Una estructura mantenida por el kernel que contiene:
- Identificador del hilo (tid)
- Estado del hilo (running, ready, waiting, start, done)
- Puntero a su stack: Apunta a su stack en el proceso
- Program counter: Apunta a la siguiente instrucción a ejecutar
- Valor de registro del hilo
- Puntero al Process control block (PCB) del proceso al que pertenece
Un hilo podría verse como una secuencia de instrucciones con su propio contexto de ejecución, y son los que efectivamente se procesan en un núcleo. Si un proceso tiene varios hilos, en realidad tiene varios flujos de ejecución independientes que pueden ejecutarse en distintos núcleos del procesador.
Tarea Link to heading
Task
Aquí es donde salimos del “scope” del sistema operativo y entramos a nivel de aplicación.
Mientras que un hilo lo definimos como la unidad básica de ejecución real, una tarea es una unidad lógica de trabajo definida por la aplicación y gestionada por una librería o runtime, no por el kernel del sistema operativo.
Las tareas representan una abstracción a nivel de aplicación, la cual es ejecutada por un hilo, y que a su vez múltiples tareas pueden compartir el mismo hilo, ejecutándose concurrentemente sobre este.
Las unidades lógicas de trabajo que pueden existir no siempre ni necesariamente se llaman “tareas”, las abstracciones son definidas a nivel de lenguaje o librería, y pueden coexistir múltiples de ellas. Esto puede llevar a confusión, especialmente porque en algunos casos incluso se las denomina “hilos” (green threads, user-space threads, etc.), pudiendo confundirse con los hilos gestionados por el sistema operativo.
Concurrencia y Paralelismo Link to heading
Concurrencia y Paralelismo son conceptos fundamentales en este tema, y que normalmente suelen ser confundidos. Aunque suenen similares, refieren a distintas formas de manejar la multitarea.
Concurrencia Link to heading
Concurrencia / Interleaving
La concurrencia la podemos definir como una forma de manejar múltiples tareas “en simultáneo”, pero no necesariamente ejecutándolas en el mismo instante de tiempo (ciclo de reloj). En cambio, las tareas se ejecutan compartiendo el tiempo de procesamiento del mismo recurso. Siendo un enfoque que se utiliza para reducir el tiempo de respuesta del sistema mediante el uso de una sola unidad de procesamiento.
Varias tareas se ejecutan sobre un mismo recurso, cambiando constantemente entre ellas, logrando así una ilusión de simultaneidad. A esto le llamamos “Interleaving”.
Y aunque hablemos de “tarea”, esto es utilizado en distintos niveles de procesamiento. Múltiples tareas se pueden ejecutar concurrentemente sobre un mismo hilo, así como múltiples hilos se pueden ejecutar concurrentemente sobre un núcleo.

Paralelismo Link to heading
En contra parte, el paralelismo refiere a efectivamente ejecutar múltiples tareas en el mismo instante, en el mismo ciclo de reloj, procesando distintas tareas en distintas unidades de procesamiento al mismo tiempo.
Hay que tener en cuenta que esto no puede lograrse ejecutando tareas sobre un mismo hilo, o hilos sobre un mismo núcleo. El paralelismo real (ejecutar varios hilos en el mismo ciclo de reloj) solamente puede ser logrado utilizando múltiples núcleos físicos del procesador, ejecutando distintos hilos en distintos núcleos a la vez.
Teniendo en cuenta el Simultaneous multithreading (SMT) de un núcleo, ejecutamos cuasi paralelamente dos hilos sobre un mismo núcleo, aunque obviamente queda muy por detrás al rendimiento de utilizar dos núcleos distintos.

Ambigüedad en las definiciones Link to heading
Existe cierta ambigüedad en la definición de concurrencia. Anteriormente definimos concurrencia como el progreso intercalado de múltiples tareas en un mismo recurso de ejecución, a lo cual le podemos llamar “interleaving”.
Pero también podemos definir concurrencia más ampliamente, simplemente como progreso simultáneo de múltiples tareas, independientemente del mecanismo.
Esta definición mas amplia de concurrencia englobaría tanto interleaving como paralelismo, y puede llevar a muchas confusiones con el termino.
Tener en cuenta que en este artículo siempre que se utilice el termino ejecución concurrente o concurrencia, me estaré refiriendo a “interleaving”.
Procesamiento y Multitarea Link to heading
Si tienes más de un hilo, entonces tienes más de un program counter, más de un conjunto de registros y más de un stack, es decir, todo lo que necesitas para que más de un núcleo se ejecute simultáneamente. Cada proceso tiene al menos un hilo, por lo que ejecutar dos procesos también te permitiría usar dos núcleos al mismo tiempo, de la misma manera que lo haría un solo proceso con dos hilos, logrando así un paralelismo real.
Esto permitiría tener paralelismo, pero en última instancia es el scheduler del sistema operativo el que distribuye los hilos a ejecutar entre los distintos núcleos disponibles. Y a la vez que envía hilos a distintos núcleos, también se mantiene ejecutando concurrentemente múltiples hilos por cada núcleo en intervalos de tiempo frecuentes. Esto permite que una cantidad arbitraria de hilos se ejecute en una cantidad arbitraria de núcleos.
Para la concurrencia, hay dos formas de decidir cuándo cambiar
- Cooperative Multitasking: Un hilo voluntariamente decide ceder el control para que otro se ejecute
- Preemptive Multitasking: Un scheduler decide cuando se ejecutan los distintos hilos
Todos los sistemas operativos modernos orientados al consumidor funcionan principalmente basados en preemptive multitasking, porque de otro modo, el mal comportamiento un programa podría evitar que otros hilos se ejecuten simplemente al no ceder el control.
Pero hay que tener en cuenta que el preemptive multitasking tiene algunos costos significativos. En particular:
- Cambiar entre hilos requiere guardar todos los registros del hilo actual en la memoria y luego cargar todos los registros del nuevo hilo desde la memoria, lo que lleva tiempo. A esto le llamamos Context Switching
- Almacenar toda la información para un hilo ocupa memoria.
- Hacer que el sistema operativo mantenga el seguimiento de los hilos requiere recursos.
Para tener un panorama general, podemos decir que
- Múltiples tareas se ejecutan concurrentemente sobre un hilo utilizando cooperative multitasking
- Múltiples hilos se ejecutan concurrentemente sobre un mismo núcleo utilizando preemptive multitasking
- Múltiples hilos se ejecutan sobre múltiples núcleos en paralelo

Bloqueo de hilos Link to heading
Poniendo como ejemplo el escenario de una API, cuando una request llega a nuestro servicio, esta se ejecutará sobre un hilo. Cuando la ejecución de la tarea es totalmente síncrona, el hilo sobre el cual se ejecute se mantendrá ocupado en su procesamiento todo el tiempo desde que la request arriba al servicio hasta que esta termina su ejecución y devuelve una respuesta. Cuando la ejecución de la request realiza una operación I/O, el hilo sobre el cual se está procesando la tarea simplemente queda esperando a que la operación se complete.
Una operación IO (Input/Output) se puede traducir como cualquier transferencia de datos o “llamada” a un sistema externo a nuestra aplicación, como por ejemplo:
- Queries/Comandos a base de datos
- Requests a servicios externos
- Interacción con periféricos
- Lectura/Escritura en disco
El servicio A realiza una llamada al servicio B y el hilo en el que se realizó la llamada espera hasta que se recibe la respuesta del servicio B, después de lo cual comienza de nuevo la ejecución secuencial del código.
Si hablamos de operaciones dependientes de CPU (CPU-bound) no tendremos otra alternativa mas que aumentar los recursos de hardware. Por otro lado, si hablamos de operaciones I/O, a pesar de que la tarea no necesite del procesamiento del CPU mientras que espera por la respuesta, esta se mantiene bloqueando al hilo. Uno de los mayores costos es pagado por cada servicio en los recursos que son desperdiciados en la espera de que se completen estas operaciones.
Programación asíncrona Link to heading
Podríamos decir que la programación asíncronca es un modelo de control de flujo en el cual una operación puede iniciarse y completarse en momentos distintos, sin que el hilo llamador espere activamente ni bloquee la ejecución del flujo principal.
Este modelo, se refiere a un estilo en el que la ejecución de la aplicación no es lineal. Los hilos delegan tareas para su ejecución y pasan a otras tareas sin esperar a que las anteriores se completen. Luego se les notifica cuando la tarea está completa y pueden reanudar la ejecución del código a partir del mismo punto donde lo dejaron.
No está de más tener en cuenta que la asincronía describe el modelo de control de flujo, no el mecanismo físico de ejecución (concurrencia o paralelismo). El lenguaje y el runtime no distinguen si el trabajo se ejecuta concurrentemente o en paralelo.
Las ventajas de este modelo son obvias, pero los detalles de implementación reales pueden ser complicados. ¿Cómo no bloqueamos los hilos cuando el lenguaje de programación utiliza un paradigma de ejecución lineal? ¿Cómo comenzamos a ejecutar nuevamente del mismo punto?
Podríamos decir que tenemos dos escenarios, aunque en realidad, se complementan entre si.
Offloading Link to heading
Este enforque se basa en delegar las operaciones bloqueantes a otros hilos de la misma aplicación.
Cuando no tenemos un soporte asíncrono real, o nuestra tarea es dependiente de CPU (CPU-bound), la alternativa utilizada para evitar el bloqueo del hilo llamador, es la delegación de la tarea bloqueante a otro hilo de la aplicación. El hilo designado lleva a cabo la tarea de bloqueo (incurriendo en la misma sobrecarga de bloqueo que el modelo de programación tradicional) y luego notifica al hilo original que la tarea ha finalizado.
Esta no sería realmente una solución al problema, simplemente una alternativa que realmente no evita el bloqueo, pero lo aísla.
Ahora bien, si estamos hablando de una operación I/O en la cual si tengamos un soporte asíncrono real, podemos optar por una mejor alternativa.
Delegar operaciones fuera de la aplicación Link to heading
Este enfoque se basa en no tener código bloqueante innecesario en nuestra aplicación en absoluto. Esto significa que un hilo inicia una operación I/O (por ejemplo, un hilo en el servicio A que envía una request a la API del servicio B) y luego es liberado totalmente para ser asignado a otra tarea. Cuando la operación I/O se completa, se notifica a la aplicación (interrupción) para que asigne un hilo y gestione el resultado de su operación (deserializando la respuesta). Desde la perspectiva de la aplicación, toda la etapa de I/O queda como responsabilidad de alguien mas, que le notificará cuando se haya realizado el trabajo. Luego, un hilo comienza a ejecutar el código desde donde se dejó antes.
Esta descripción puede sonar similar al enfoque basado en offloading, pero hay una diferencia vital. Mientras en el enfoque anterior un hilos secundario se encargaba de la operación I/O bloqueante, en este enfoque no hay código bloqueante para nada. No necesitamos un hilo secundario el cual se encargue de la operación bloqueante y notifique su finalización. Pero eso deja la duda, ¿Quien es el que se encarga de procesar y notificar la finalización de la operación I/O?
La respuesta es, el sistema operativo.
Los sistemas operativos, durante mucho tiempo han tenido soporte para aceptar un hook I/O desde una aplicación, manteniendo un seguimiento del ciclo de vida de la operación, y notificando a la aplicación cuando esta se complete. Aprovechando esto, una aplicación puede desentenderse por completo de la gestión de las operaciones I/O, y solamente centrarse en lanzarlas y manejar su resultado.

Manejo del desligue de la tarea Link to heading
La forma en que se ejecuta la operación I/O es que la aplicación abre un socket y luego ejecuta comandos para comenzar a enviar y recibir datos a través de el. Una vez que se envían los datos de la request, una aplicación con ejecución síncrona simplemente esperaría realizando un polling al socket para ver cuándo se recibe el resultado. Este es el origen del bloqueo de hilos en operaciones I/O.
Sin embargo, las aplicaciones con ejecuciones asíncronas no bloqueantes delegan completamente esta etapa de espera al sistema operativo. Los sistemas operativos cuentan con una api dedicada justamente a este objetivo (epoll en linux, IOCP en windows) el cual puede manejar el polling de los sockets en busca de datos de manera muy eficiente. La aplicación agrega su propio socket a la lista de sockets que están siendo sondeados y le proporciona un hook (también conocido como callback) que la api del SO puede usar para informar a la aplicación cuando el socket recibe la respuesta y el flujo de datos entrante está listo para procesarse. El hilo de la aplicación ahora está libre para procesar otras tareas.
Ya que no hay operaciones I/O bloqueantes de ningún tipo, los hilos de la aplicación ahora son libres de procesar tareas no bloqueantes, lo que le da a la aplicación un manejo mucho mas eficiente de los recursos y la capacidad de manejar una escala masiva.
Manejo de la reasignación de la tarea Link to heading
La api del SO se mantendrá realizando el polling a todos los sockets registrados en ella, normalmente utilizando un único hilo. Una vez que un socket recibe datos, invoca el callback que le ha dado la aplicación con los datos recibidos y el contexto de ejecución del hilo (también almacenado aquí dado por la aplicación). Por supuesto, la api del SO no entiende qué son los datos, para el simplemente son algunos bytes que la aplicación puede procesar. Este callback interrumpe un hilo de la aplicación para continuar con su ejecución.
Abstracciones Link to heading
A pesar de tener dos enfoques totalmente distintos (offloading y delegación al SO), en última instancia, los lenguajes modernos ofrecen abstracciones (async/await, promises, futures) que ocultan estas diferencias de implementación. Dependiendo del entorno, del tipo de operación, y como diseñemos nuestro código, el runtime decidirá si usa offloading o delegación real al sistema operativo.
Un gran poder conlleva una gran responsabilidad Link to heading
Cuando introducimos la capacidad de ejecutar múltiples hilos en simultaneo (independientemente del mecanismo), lamentablemente también introducimos múltiples problemas a solucionar.
Este puede ser un punto un poco complicado de entender, ya que hay muchos factores que lo afectan de maneras distintas, como las arquitecturas de cpu, los modelos de memoria, los sistemas de coherencia de cache o las optimizaciones de compilación.
No explicaré a profundidad cada punto, pero daré una breve explicación para tener un poco de contexto de cada uno
Factores involucrados Link to heading
Arquitectura de CPU Link to heading
Cada arquitectura (x86, ARM, RISC-V, etc.) define su propio modelo de ejecución y orden de memoria a nivel de hardware.
Por ejemplo:
-
x86 tiene un modelo TSO (Total Store Order), bastante estricto: las escrituras se ven en orden, pero las lecturas pueden adelantarse.
-
ARM o RISC-V por otra parte tienen modelos más flexibles, lo que significa que permiten más reordenamientos internos para optimizar rendimiento.
Esto impacta directamente la consistencia observable entre hilos: dos CPUs distintos pueden ejecutar el mismo código con diferentes resultados.
Modelos de memoria Link to heading
El modelo de memoria define qué comportamientos son válidos y cuáles no cuando varios hilos acceden a memoria compartida.
Hay dos niveles importantes:
-
Modelo de memoria del hardware (definido por la CPU): lo que vimos en el punto anterior.
-
Modelo de memoria del lenguaje: qué garantías ofrece el compilador al programador.
Los modelos de memoria garantizan ciertos comportamientos en distintos escenarios, como los reordenamientos de instrucciones posibles y que barreras de memoria se establecen.
Protocolo de coherencia de caché Link to heading
Cada núcleo de CPU tiene su propia caché local, y el sistema debe mantenerlas coherentes. Esto significa que si múltiples núcleos tienen una copia cacheada de un recurso de la misma región de memoria compartida, el sistema debe de asegurar que todas las copias sean iguales.
Por ejemplo, cuando un núcleo escribe en una variable, los demás núcleos que tienen su valor en caché local deben invalidar su copia. Si más adelante necesitan leer ese valor, detectarán que su copia es inválida y deberán obtenerlo nuevamente desde otra caché o desde memoria.
Optimizaciones de compilación Link to heading
Los compiladores también pueden reordenar o eliminar operaciones si no detectan dependencias aparentes. Por ejemplo, el compilador podría:
-
Mantener una variable en un registro en lugar de recargarla de memoria.
-
Reordenar instrucciones para mejorar el uso del pipeline.
Posibles problemas Link to heading
Condiciones de carrera Link to heading
race conditions
Ocurren cuando dos o más hilos acceden simultáneamente a un mismo recurso (como puede ser una variable) al cual ambos tienen acceso, y al menos uno de ellos realiza una escritura.
Al realizar la escritura, la lectura del resto de hilos puede verse corrompida y ser totalmente imprecisa con la realidad, lectura que incluso luego puede resultar en una subsecuente escritura también errónea.
Decimos que la ejecución es no determinista, ya que el resultado depende del orden o “timing” de ejecución de los distintos hilos, teniendo resultados inesperados e inconsistentes. Sin mencionar su dificultad para reproducirlo o depurarlo.
Ejemplo:
Dos hilos incrementando una variable global, lo que debería resultar en counter = 2
- Thread A lee
counter = 0 - — cambio de contexto —
- Thread B lee
counter = 0 - Thread B escribe
counter = 1 - — cambio de contexto —
- Thread A escribe
counter = 1
Resultando en counter = 1
Ya que la operación de “incrementar en uno el contador” no es atómica (de las que hablaremos luego) sino que por debajo son múltiples operaciones separadas (lectura + suma + escritura), un hilo puede interferir mientras la operación está en un estado parcial de completitud. Obteniendo un resultado inconsistente, dependiente de factores incontrolables.
Deadlocks Link to heading
Una de las formas mas comunes de lidiar con las condiciones de carrera, y con la mayoría de problemas resultantes de compartir recursos entre múltiples hilos, es la implementación de locks.
Los deadlocks generalmente son el resultado de una mal implementación de estos. Un estado en el cual múltiples hilos están bloqueados entre si, esperando por el liberamiento de un lock sobre un recurso el cual necesitan para continuar su ejecución.
Por ejemplo, el hilo 1 podría estar esperando el recurso A, que está bloqueado por el hilo 2, mientras que este último espera el recurso B, bloqueado a su vez por el hilo 1. Entrando en un estado en donde ambos hilos se bloquean entre si y nunca pueden avanzar con su ejecución.
Livelocks Link to heading
Un livelock es una situación similar a un deadlock, en la que dos o más hilos no pueden avanzar en su ejecución. Sin embargo, a diferencia del deadlock, los hilos no están completamente bloqueados, sino que siguen ejecutando acciones, pero sin lograr ningún progreso real.
Esto suele ocurrir cuando los hilos intentan evitar un deadlock liberando recursos y volviendo a intentarlo continuamente, pero ambos lo hacen de forma simultánea, entrando en un ciclo infinito de reintentos.
Por ejemplo, si dos hilos intentan adquirir dos recursos en el mismo orden y, al detectar un posible conflicto, liberan sus recursos y vuelven a empezar, podrían terminar cediendo mutuamente el paso una y otra vez sin que ninguno logre completar su tarea.
En resumen, en un deadlock los hilos están bloqueados y no hacen nada, mientras que en un livelock están activos, pero sin avanzar.
Inversión de prioridades Link to heading
Priority Inversion
La inversión de prioridades es el escenario donde una tarea de alta prioridad es indirectamente degradada por otra con una prioridad mas baja, efectivamente invirtiendo las prioridades asignadas a ellas.
Por ejemplo, la tarea 0 de prioridad alta intenta acceder a un recurso compartido el cual ya está siendo utilizado y bloqueado por la taea 1 de prioridad baja. Sería coherente que en este caso, la tarea 1 ceda el recurso a la tarea 0 de prioridad mas alta, pero que pasaría si una tarea 2 de prioridad media, interrumpiese a tarea 1 antes de que está pudiera ceder el recurso?
Terminaríamos en un escenario donde la tarea de mas alta prioridad está siendo bloqueada por otra con prioridad mas baja.
Un interesante caso real de esto es el robot de exploración de marte Mars Pathfinder, el cual su software bajo ciertas cargas pesadas de trabajo sufría de inversión de prioridades, principalmente al verificar el código de entrada y aterrizaje. Esto causando 4 reinicios del sistema, demorando y complicando la misión. Aunque por suerte el problema fue encontrado y parcheado desde tierra.

Inconsistencias de memoria Link to heading
Las inconsistencias de memoria, o problemas de visibilidad de memoria, las podemos separar en dos escenarios distintos, ordenamiento y visibilidad.
Ordenamiento Link to heading
A pesar de que en un primer momento creamos que las instrucciones de nuestro código se ejecutan en el orden tal cual las escribimos, esto no es así. Las instrucciones son reordenadas tanto por el compilador en el proceso de traducción del código fuente a machine-code, como por el CPU gracias al out-of-order execution el cual explicamos anteriormente, y por las optimizaciones del sistema de memoria.
En todos los casos, este reordenamiento se da con el objetivo de optimizar la ejecución del código. Y tiene como regla principal, no afectar su comportamiento final.
Dado el siguiente ejemplo
x = true;
y = true;
return (!x && y); ---Return false
Podría reordenarse de forma que y = true se ejecutase antes que x = true sin afectar su comportamiento.
Ahora, si no afecta su comportamiento, cual es el problema entonces?
Este reordenamiento asegura que su comportamiento no se vea afectado siempre y cuando estemos hablando de una ejecución sobre un solo hilo. Cuando incluimos otros hilos a la ecuación, podríamos decirle “observadores externos”, el reordenamiento puede afectar a su resultado final.
Dado el mismo ejemplo, si la asignación de x e y se ejecuta sobre un hilo, mientras la comprobación (!x && y) se ejecute sobre otro simultaneamente. Puede darse el caso de que y = true se ejecute primero, y al momento de comprobar (!x && y) aún no se ejecute x = true.
(n) = orden de ejecución
--- Hilo 1 ---
x = true; ---(3)
y = true; ---(1)
--- Hilo 2 ---
return (!x && y) ---(2) ---Return true
También lo podemos ver de la siguiente manera
y = true;
--- Cambio de contexto ---
return (!x && y) ---Return true
--- Cambio de contexto ---
x = true;
Visibilidad Link to heading
Como mencionamos, cada núcleo tiene su propia caché local, pero también a su vez, cada hilo mantiene su propio conjunto de registros, donde puede almacenar copias temporales de variables utilizadas durante su ejecución.
Los registros no se almacenan en caché, tienen su propio almacenamiento, el cual es aún mas rápido y se encuentra mas cerca de las unidades de ejecución, incluso en el mismo bloque de silicio que el scheduler o el execution engine. Por lo tanto, el protocolo de coherencia de caché no los afecta.
El problema de esto, es que no siempre hay una visibilidad total entre las acciones de los distintos hilos. Por ejemplo, si un hilo carga una variable compartida en uno de sus registros y continúa ejecutándose sin re-leer su valor desde memoria compartida, las modificaciones que otros hilos realicen sobre dicha variable en memoria no serán visibles para él.
Dado el siguiente ejemplo, en .NET 8, compilando para x64 y con optimización de código activada
static class Program
{
static bool ok = false;
static void Loop()
{
int n = 0;
while (!ok) ++n;
Console.WriteLine("Loop Ended");
}
static void Main()
{
new Thread(Loop).Start();
Console.WriteLine("Loop Started");
ok = true;
}
}
Estamos lanzando Loop() en un hilo, y luego en otro modificamos el valor de la variable ok a true, lo que debería de finalizar la ejecución. Pero en este caso, la modificación de la variable nunca es vista por el segundo hilo y el bucle nunca finaliza. Esto es en definitiva, un problema de visibilidad de memoria.
Si en este caso, le agregásemos la keyword volatile a la definición de la variable ok, se insertarían ciertas barreras de memoria que nos asegurarían de que:
- Cada lectura de
okse lee directamente desde memoria compartida, no desde un registro o caché local. - Cada escritura a
okse propaga inmediatamente a memoria compartida, de modo que otros hilos la vean. - Se impida reordenar operaciones alrededor de esas lecturas/escrituras.
Es muy importante tener en cuenta que en particular la keyword volatile no asegura atomicidad, no previene condiciones de carrera, ni asegura ordenamiento para otras operaciones de memoria. Y en otros lenguajes como C++, volatile solamente evita la optimización del compilador al rededor de la variable, pero ni siquiera utiliza barreras de memoria o asegura ordenamiento ninguno.
Con este mismo ejemplo, también podemos ver un comportamiento interesante en la optimización del código por parte del compilador.
Si en este mismo código no tuviésemos la operación ++n dentro del bucle, y simplemente utilizáramos un while vacío, el compilador decide leer ok desde memoria, y no incurriríamos en un problema de visibilidad.
Parece brujería, verdad?
Esto se da ya que en el primer escenario n se utiliza dentro del bucle, por lo que el compilador asume que ok no cambia durante este, y aplica una optimización cacheando el valor de ok en los registro del hilo.
Por otra parte, en el segundo escenario el bucle no tiene cuerpo, y la optimización se limita ya que el bucle posiblemente se interprete como un probable “punto de espera” intencional.
Como podemos ver, esto depende de la interpretación del compilador y las optimizaciones que aplique en consecuencia. Por lo que hay que entender que esto no es algo en lo que nos tengamos que basar para diseñar nuestro código. Es mejor verlo como un “efecto accidental” del comportamiento de la optimización. Para diseñar nuestro código, tenemos que utilizar las distintas herramientas que nos brinda el lenguaje.
Aun así, es importante saber que existen, ya que nos podría pasar que nuestro código tenga un comportamiento distinto al compilar para debugging (donde las optimizaciones generalemente no están activadas) que al hacerlo para release (donde si lo están), o para distintas arquitecturas.
Contención de recursos Link to heading
Resource contention
La contención de recursos es una situación que ocurre cuando dos o más hilos, procesos o tareas intentan acceder simultáneamente a un mismo recurso limitado, como puede ser una sección de memoria, un archivo, una conexión de red, un lock, o incluso tiempo de CPU.
Veamos alguno tipos de contención interesantes.
False Sharing Link to heading
Para entender este punto, primero necesitamos entender un concepto básico del funcionamiento de la memoria.
En la memoria, cada byte tiene su dirección de memoria (memory address), siendo la unidad mínima de almacenamiento “addressable” (a la cual le podemos asignar una dirección). Y a su vez en la caché (a todos sus niveles) los datos se organizan en “bloques”, a los cuales llamamos líneas de caché (cache lines), siendo la unidad mínima de transferencia entre los distintos niveles de memoria, las cuales típicamente son de 64 bytes.
Cuando se necesita copiar un dato desde memoria, se copia toda la linea de memoria donde este se encuentra. Es decir, cuando el CPU necesita traer datos desde RAM, el controlador de caché pide un bloque de 64 bytes (una línea completa), donde también puede haber otros datos.

Teniendo esto en cuenta, y recordando el funcionamiento del sistema de coherencia de caché. Cuando un dato es modificado por un hilo en un núcleo, debe ser marcado como invalidado en las caché del resto de núcleos. Pero al hacer esto, se invalida la linea de caché completa, incluyendo el resto de datos que pueda haber en ella.
Cuando un dato es invalidado por el hecho de compartir linea de cache con otro el cual fue modificado en otro núcleo, estamos frente a un false sharing.
Por ejemplo, el hilo 0 modifica una variable en la direcciones de memoria 0x00, y otra variable que comparte la misma linea en la dirección 0x07 y la cual es requerido por el hilo 1, se ve invalidado a pesar de no haber sido modificada. Obligando a que se vuelva a copiar desde memoria, lo cual sabemos es bastante demorado.
A su vez, el hilo 1 luego modifica la variable en la dirección 0x07 invalidando nuevamente la linea. Entrando en un bucle donde ambos se invalidan entre si, el cual se traduce en una perdida de rendimiento y un aumento de latencia, y que escala exponencialmente con la carga de trabajo, destruyendo la escalabilidad de nuestro sistema.

Un muy buen ejemplo de esto, y una lectura que les aseguro que merece la pena, es el caso de netflix. Donde a la hora de escalar uno de sus microservicios (con una carga de trabajo mayormente dependiente de CPU) desplegado en AWS de 16 vCPUs a 48 vCPUs por nodo, notaron un incremento mínimo en el rendimiento, bajo la misma carga de CPU, acompañado de un gran aumento en la latencia.
True Sharing Link to heading
Si decíamos que el false sharing es cuando dos variables independientes que comparten la misma linea de caché se invalidan entre si en múltiples núcleos, true sharing es cuando efectivamente una misma variable es escrita y leída por múltiples hilos/núcleos constantemente.
Incurriendo, aunque por una causa distinta, en el mismo escenario anterior. Donde tendremos una perdida de rendimiento, un aumento de latencia, y una muy mala escalabilidad de nuestro sistema.
En este caso, no tendremos otra opción mas que re diseñar el comportamiento de nuestro código para de alguna forma mitigar el uso del recurso compartido.
Sobrecarga por cambios de contexto Link to heading
Como vimos antes, el sistema operativo utiliza preemtive multitasking para constantemente cambiar los hilos en ejecución en intervalos de tiempo frecuentes, repartiendo el tiempo de CPU entre todos los hilos. Este cambio entre hilos lo conocemos como context switching.
También como dijimos, este context switching no es gratis. Cada vez que se necesita cambiar de hilo, se debe de guardar y restaurar registros, TLB, datos necesarios en caché, etc.
En un escenario donde tenemos un limitado número de núcleos a la vez que un gran número de hilos prontos para ejecutarse, los cambios de contexto son cada vez mas frecuentes en cuanto crece el número de hilos. Pudiendo incluso llegar al punto donde se gaste mas tiempo de CPU cambiando de contexto que ejecutando trabajo real.
Starvation Link to heading
inanición (?
Resource starvation podríamos definirlo como una etapa o tipo limite de contención de recursos donde un hilo, proceso o tarea es constantemente denegado del acceso a recursos compartidos necesarios para continuar con su ejecución, por lo tanto nunca logrando progresar.
Por ejemplo, si el hilo 0 e hilo 1 siempre se están intercambiando entre si el adueñamiento de un lock sobre un recurso compartido, mientras un hilo 2 nunca puede tener acceso a el, este no podrá progresar en su ejecución, y es cuando decimos que hay starvation.
Thread safety Link to heading
Una función es “thread-safe” cuando puede ser invocada o accedida simultáneamente por múltiples hilos sin causar un comportamiento inesperado, los cuales describimos anteriormente. Como en el contexto donde un programa ejecuta múltiples hilos simultáneamente en un espacio de direcciones compartido y cada uno de esos hilos tiene acceso a la memoria del resto, las funciones thread-safe necesitan asegurar que todos esos hilos mantengan su comportamiento esperado sin interacciones no deseadas.
Existen varias estrategias para implementar estructuras de datos thread-safe, que podríamos separar en en dos clases.
Evitar recursos compartido Link to heading
Como vimos, la mayoría de problemas son relacionados a la gestión de recursos compartidos por varios hilos. Y la mejor manera de solucionar un problema, es no tenerlo en un primer momento.
El primer enfoque se centra en evitar un estado en el que distintos hilos compartan recursos.
Código Reentrante Link to heading
Re-entrancy
Escribir código de cierta forma en la cual pueda ser parcialmente ejecutado por un hilo, ejecutado por el mismo hilo, o simultáneamente ejecutado por otro hilo y aun así completar correctamente su ejecución original. Esto requiere de guardar la información del estado en variables locales a cada ejecución, en lugar de en variables estáticas, globales, o de cualquier forma no local.
En otras palabras, una función reentrante no depende de datos estáticos, globales ni de recursos compartidos que puedan ser modificados durante su ejecución.
Almacenamiento local por hilos Link to heading
Thread-local storage
El thread-local storage (TLS) es una técnica que permite que cada hilo mantenga su propia copia independiente de una variable, aun cuando esta se declare de forma global o estática. Esto significa que diferentes hilos pueden acceder a “la misma variable” simbólica sin interferir entre sí, ya que en realidad cada uno opera sobre una instancia separada en memoria.
El TLS se utiliza para evitar condiciones de carrera cuando varios hilos necesitan almacenar datos temporales o de contexto propios (por ejemplo, contadores, buffers o identificadores), sin necesidad de usar mecanismos de sincronización como locks.
Objetos Inmutables Link to heading
Immutable objects
Un paradigma donde el estado de un objeto no puede ser modificado luego de su construcción. Esto implica que los datos compartidos son de solo lectura y que son intrínsecamente thread-safe. Operaciones que resulten en modificaciones del objeto, en lugar de modificar los existentes, crearían una nuevas instancia de este.
Sincronización Link to heading
El segundo enfoque es basado en sincronización, y generalmente es utilizado en situaciones donde el compartir recursos no puede evitarse.
Los mecanismos de sincronización permiten coordinar el acceso y la visibilidad de los datos compartidos, evitando condiciones de carrera o inconsistencias.
Barreras de Memoria Link to heading
memory fences
Las barreras de memoria son instrucciones especiales que restringen el reordenamiento de operaciones de lectura y escritura, tanto por parte del compilador como del CPU.
Garantizan un cierto orden de visibilidad de las operaciones de memoria entre distintos hilos o núcleos.
Por ejemplo, en x86, la instrucción MFENCE actúa como una barrera completa, asegurando que todas las escrituras anteriores sean visibles antes de realizar las posteriores.
En los lenguajes de alto nivel, las barreras suelen estar implícitas en ciertas operaciones atómicas o primitivas de sincronización.
Operaciones Atómicas Link to heading
Atomic operations
Las operaciones en el código, como puede ser x++, son traducidas a múltiples instrucciones maquina. En el caso de x++ (Incrementar en 1 la variable x), en x86 es traducida a las siguiente tres instrucciones
MOV EAX, [x] -- Cargar
INC EAX -- Incrementar
MOV [x], EAX -- Guardar
Por esto mismo, la operación x++ no es atómica, ya que es compuesta por tres pasos, donde un “observador externo” (otro hilo) podría interferir entre medio.
Sin embargo, también a nivel de machine code, existen cierto tipo de instrucciones capaces de realizar ciertas operaciones predefinidas de forma atómica. Asegurando que la operación sea totalmente indivisible, y que para cualquier observador externo esta se perciba como “instantánea”.
Una de estas es “fetch-and-add”, la cual incrementa el contenido de una dirección de memoria de forma atómica. Traduciendo lo anterior a la siguiente instruccion x86
LOCK XADD [x], 1
En base a esto los distintos lenguajes definen “operaciones atómicas” las cuales generalmente son mappeos 1:1 a estas instrucciones atómicas en machine code. Como por ejemplo en .NET la operacion Interlocked.Increment(ref x) es un mappeo 1:1 a la instrucción fetch-and-add.
Sin embargo, no siempre son una correspondencia directa 1:1, porque el lenguaje o el runtime debe respetar un modelo de memoria más fuerte y portable que el del hardware, manteniendo la misma funcionalidad de su operación atómica en distintos escenarios. Para esto, puede utilizar barreras de memoria y otras instrucciones. Lo importante para nosotros, es que desde nuestro punto de vista las operaciones atómicas definidas por el lenguaje siempre serán efectivamente atómicas.
Estas operaciones atómicas las utilizamos para sincronizar de manera muy especifica ciertas operaciones en nuestro código.
Exclusión Mutua Link to heading
Mutual exclusion
A diferencia de las operaciones atómicas, que sincronizan acciones muy específicas, los mecanismos de exclusión mutua sincronizan secciones completas de código, garantizando exclusividad temporal sobre un recurso.
Estos son primitivas de sincronización basadas en bloqueos (locks), construidas a partir de operaciones atómicas combinadas con barreras de memoria, y en muchos casos recurren también a mecanismos del sistema operativo para gestionar la espera y el despertar de hilos cuando el bloqueo no puede resolverse en espacio de usuario.
Estos mecanismos los utilizamos para sincronizar código con cierto grado de complejidad, donde las operaciones atómicas no nos son suficiente.
En .NET algunas de ellas pueden ser lock, Monitor, mutex, spinlock y semaphore.
Coste de rendimiento Link to heading
Con el simple hecho de tener sincronización, tenemos un inevitable coste de rendimiento. Pero es muy importante tener en cuenta que cada método de sincronización tiene un costo totalmente distinto. Que puede ir de decenas de ciclos de reloj, a cientos o miles.
Estos también varían según varios factores. Como por ejemplo la arquitectura del CPU, donde x86-64 al tener de por si un modelo de memoria mas restrictivo, las barreras de memoria explicitas no son tan costosas de rendimiento como lo serían en arquitecturas como ARM.
En general, el coste de rendimiento escala de menos a mas, de la siguiente forma:
Barreras de memoria < Operaciones atómicas < Locks (y dentro de estos, cada uno tiene su propio coste)
También, el hecho de no utilizar locks (lock-free) nos ahorra posibles problemas como deadlocks o livelocks.
Nuestro objetivo es saber utilizar de la manera mas eficiente posible estas herramientas, a la vez que construimos un código robusto.
Implementación de una API en .NET Link to heading
Veamos un poco una implementación sencilla y de alto nivel de una API. No me centraré en los mecanismos que vimos anteriormente, simplemente será código reentrante para ver un poco el contexto de .NET.
Gestión de hilos Link to heading
La creación, destrucción y asignación de hilos en los escenarios de ejecución controlados por el runtime en .net está gestionada por el ThreadPool (Pool de hilos), un sistema de gestión de hilos a nivel de software de .net runtime.

A través de la clase System.Threading.ThreadPool .net provee de este pool de hilos gestionados por el sistema, quitándole al usuario la responsabilidad de la gestión de hilos en la aplicación en la mayoría de los casos.
El pool de hilos de .net provee 2 configuraciones:
-
MaximumValue: El número total de hilos que pueden ser creados. Este valor depende de varios factores, como el tamaño de la memoria swap, pero principalmente del número de procesadores lógicos del CPU.
-
MinimumValue: El número de hilos que pueden ser creados antes de que .net comience el proceso de thread throttling.
NotePor defecto, el mínimo de hilos es igual al número de procesadores lógicos (núcleos virtualmente divididos con SMT), aunque este valor puede variar según el tipo de hilo y el entorno de ejecución. Lo que no quiere decir que la aplicación se iniciará con este valor, si el valor es modificado, la aplicación aún así se iniciará con tantos hilos en el pool de hilos como procesadores lógicos presentes en el CPU.
En una ejecución síncrona donde el hilo asignado a la tarea de una request entrante se mantiene bloqueado hasta la finalización de la tarea, si todos los hilos disponibles están asignados/bloqueados, el pool de hilos tendrá que crear un nuevo hilo por cada request entrante.
Esto quiere decir que si tenemos un pico de tráfico de un gran número de requests, el pool de hilos tendrá que crear un hilo para cada una de las request, lo que terminará en una gran cantidad de hilos siendo creados.
La creación, destrucción, cambio de contexto o la propia existencia de los hilos por si misma, es bastante demandante de recursos, por lo que crear y mantener una gran cantidad de hilos es algo que queremos evitar lo mas posible.
Thread Throttling y Thread Starvation Link to heading
Para evitar esto el pool de hilos de .net cuenta con un mecanismo de throttling. Cuando el MinimumValue de hilos es alcanzado, se crean nuevos hilos gradualmente, aplicando un algoritmo de “hill climbing”, agregando un tiempo de delay en la creación de nuevos hilos de 500ms. Esto quiere decir que si por ejemplo, luego de superado el limite, aún hay 100 requests para procesar, la primera tendrá un delay de 500ms, mientras que la número 100 (si no se libera ningún hilo en el proceso) tendrá un delay de 50 segundos antes de procesarse (500ms x 100), lo que probablemente provoque un timeout. Aquí estaríamos incurriendo en Thread Starvation.
Si simplemente aumentamos el MinimumValue, el pool de hilos creara tantos hilos como sea necesario para las request entrantes sin aplicar ningún mecanismo de throttling antes de alcanzar el limite configurado. Y aunque esto en un primer momento pueda parecer beneficioso, ocasiona un disparo en el consumo de recursos. Tanto en memoria ya que cada hilo del ThreadPool tiene su propio stack, como en CPU al saturar el scheduler con la cantidad de cambios de contexto entre la gran cantidad de hilos, llegando incluso a gastar mas tiempo de CPU cambiando de contexto que ejecutando trabajo real.
Abstracciones Link to heading
La programación asíncrona en .NET ha evolucionado a través de varios modelos a lo largo de las versiones del framework.
Threading Clásico (.NET Framework 1.0, 2002) Link to heading
Desde sus inicios, .NET proporcionó control directo sobre hilos mediante:
- La clase
Threadpara crear y gestionar hilos manualmente ThreadPoolpara reutilización eficiente de hilos del sistema- Sincronización primitiva con
Monitor,Mutex,Semaphore
Este enfoque ofrece control total pero requiere una gestión totalmente manual.
Asynchronous Programming Model - APM (.NET Framework 1.0, 2002) Link to heading
El primer patrón asíncrono formal introducido en .NET, basado en métodos Begin/End:
- Métodos como
BeginRead/EndRead,BeginWrite/EndWrite - Uso de
IAsyncResultpara representar operaciones asíncronas - Callbacks mediante
AsyncCallback
Aunque funcional, resultaba verboso y propenso a errores de gestión de estado.
Event-based Asynchronous Pattern - EAP (.NET Framework 2.0, 2005) Link to heading
Un patrón más orientado a eventos que simplificaba algunas operaciones:
- Métodos terminados en
Async - Eventos de completación terminados en
Completed - Propiedades como
IsBusyy métodosCancelAsync
Aunque más simple que APM para escenarios básicos, se volvía complejo al manejar múltiples operaciones concurrentes.
Task Parallel Library - TPL (.NET Framework 4.0, 2010) Link to heading
Introdujo el concepto fundamental de Task como unidad de trabajo asíncrono:
- Clase
TaskyTask<T>para representar operaciones asíncronas Parallel.For,Parallel.ForEach,Parallel.Invokepara paralelismo de datosCancellationTokenpara cancelación cooperativa- Mejor composición mediante continuaciones con
ContinueWith
TPL fue revolucionario, pero las continuaciones anidadas aún podían resultar en código difícil de leer.
Parallel LINQ - PLINQ (.NET Framework 4.0, 2010) Link to heading
Extensión paralela de LINQ introducida junto con TPL:
- Método de extensión
.AsParallel()para paralelizar consultas LINQ - Particionamiento automático de datos entre hilos
- Configuración mediante
WithDegreeOfParallelism,WithExecutionMode, etc.
Ideal para procesamiento paralelo de colecciones cuando el orden no es crítico o puede gestionarse.
Task-based Asynchronous Pattern - TAP (.NET Framework 4.5, 2012) Link to heading
El modelo actual y recomendado, que introdujo las palabras clave async y await:
- Sintaxis natural que permite escribir código asíncrono con apariencia secuencial
- Métodos que retornan
TaskoTask<T>por convención - Integración nativa con el compilador para generación de máquinas de estado
Ventajas del Modelo Actual (TAP) Link to heading
La programación asíncrona basada en tareas simplifica significativamente la gestión de operaciones asíncronas. En lugar de gestionar directamente eventos, callbacks o complejas interacciones de estado como en APM y EAP, TAP encapsula estas operaciones dentro de un objeto Task, lo que permite:
Simplificación del flujo de control Link to heading
Con async/await, el código se lee de forma secuencial y natural, mientras que internamente las tareas siguen ejecutándose de manera no bloqueante.
Manejo intuitivo de errores Link to heading
El uso de bloques try/catch dentro de métodos async permite manejar excepciones asíncronas de la misma manera que el código síncrono, eliminando la complejidad de verificar estados de error en callbacks.
Composición de tareas Link to heading
Es significativamente más fácil componer y combinar operaciones asíncronas usando:
Task.WhenAllpara esperar múltiples tareas en paraleloTask.WhenAnypara reaccionar a la primera tarea completadaTask.Delaypara temporización asíncrona- Encadenamiento natural de operaciones con
await
Cancelación unificada Link to heading
CancellationToken proporciona un mecanismo consistente para cancelación cooperativa a través de todas las operaciones asíncronas.
En resumen, la programación basada en tareas (TAP) es una abstracción moderna y simplificada que oculta la complejidad subyacente de la programación basada en eventos y callbacks. En su núcleo, el sistema operativo y la infraestructura de .NET siguen utilizando mecanismos como I/O Completion Ports, operaciones del kernel y el thread pool para manejar la asincronía de manera eficiente.
Ejemplos de código Link to heading
Teniendo como ejemplo la implementación de las siguientes operaciones I/O, las cuales son llamadas a una base de datos
public Medic GetMedic(int medId);
public Patient GetPatient(int indId);
public Institution GetInstitution(int insId);
public void InsertDocument(UnstructuredDocument);
La utilización de estas operaciones dentro de un endpoint podría ser la siguiente
public ActionResult<Result> RegisterDocument([FromBody] DocumentDto inputDocument)
{
try
{
Document document = new Document()
{
DocumentOID = 123;
}
document.Medic = _dbRepository.GetMedic(inputDocument.MedicId);
document.Patient = _dbRepository.GetPatient(inputDocument.PatientId);
document.Institution = _dbRepository.GetInstitution(inputDocument.InstitutionId);
_dbRepository.InsertDocument(document);
return Ok(Result.Success($"Se ha registrado con exito el documento '{document.DocumentOID}'"));
}
catch (Exception ex)
{
return StatusCode(500, Result.Failure($"Error al registrar nuevo documento: {ex.Message}"));
}
}
Teniendo en cuenta que la gran mayoría del trabajo en este endpoint es realizando operaciones I/O, en una ejecución síncrona la cual bloquea el hilo de ejecución durante todo el proceso, cuanto tiempo del total de la ejecución es desperdiciado simplemente esperando por la finalización de las operaciones I/O? podríamos decir que incluso mas de un 80 o 90%. Esto se traduce directamente a un gasto de recursos y crece de manera exponencial con la cantidad de requests.
Ahora implementemos esto de forma asíncrona y no bloqueante:
Para esto, los métodos declarados para los operaciones I/O deben ser asíncronos y tendrán que definirse como async y retornar Task
public async Task<Medic> GetMedicAsync(int medId);
public async Task<Patient> GetPatientAsync(int indId);
public async Task<Institution> GetInstitutionAsync(int insId);
public async Task InsertDocument(UnstructuredDocument);
Y el endpoint que las utiliza debe definirse como async, retornar Tasky utilizar await para sus llamadas
public async Task<ActionResult<Result>> RegisterDocument([FromBody] DocumentDto inputDocument)
{
try
{
Document document = new Document()
{
DocumentOID = 123;
}
document.Medic = await _dbRepository.GetMedicAsync(inputDocument.MedicId);
document.Patient = await _dbRepository.GetPatientAsync(inputDocument.PatientId);
document.Institution = await _dbRepository.GetInstitutionAsync(inputDocument.InstitutionId);
await _dbRepository.InsertDocument(document);
return Ok(Result.Success($"Se ha registrado con exito el documento '{document.DocumentOID}'"));
}
catch (Exception ex)
{
return StatusCode(500, Result.Failure($"Error al registrar nuevo documento: {ex.Message}"));
}
}
Como se puede ver, con la abstracción que provee async/await, el resultado es un código simple y legible que tiene una apariencia muy similar a su solución síncrona. Pero que a su vez maneja los hilos, y por lo tanto los recursos de hardware, de manera mucho mas eficiente.
En esta implementación, cuando llega el momento de ejecutar una operación I/O, esta es totalmente delegada al sistema operativo, y el hilo encargado de ejecutar la request es liberado, pudiendo ser asignado a otra request distinta.
Pero este código tiene aun mas margen de mejora, ya que al ser las operaciones I/O asíncronas, podemos incluso paralelizar todas esas operaciones no dependientes entre si.
public async Task<ActionResult<Result>> RegisterDocument([FromBody] DocumentDto inputDocument)
{
try
{
Document document = new Document()
{
DocumentOID = 123;
}
getMedicTask = _dbRepository.GetMedicAsync(inputDocument.MedicId);
getPatientTask = _dbRepository.GetPatientAsync(inputDocument.PatientId);
getInstitutionTask = _dbRepository.GetInstitutionAsync(inputDocument.InstitutionId);
await Task.WhenAll(getMedicTask, getPatientTask, getInstitutionTask);
document.Medic = getMedicTask.Result;
document.Patient = getPatientTask.Result;
document.Institution = getInstitutionTask.Result;
await _dbRepository.InsertDocument(document);
return Ok(Result.Success($"Se ha registrado con exito el documento '{document.DocumentOID}'"));
}
catch (Exception ex)
{
return StatusCode(500, Result.Failure($"Error al registrar nuevo documento: {ex.Message}"));
}
}
En este caso paralelizamos la ejecución de 3 de las 4 operaciones, ya que mientras InsertDocument es dependiente de el resto de operaciones, las operaciones Get no son dependientes entre si, por lo tanto pueden paralelizarse.
Esto no solo maneja los hilos de una forma mas eficiente, sino que también disminuye el tiempo de respuesta del endpoint!!
Como ven, este es un ejemplo muy básico, donde no tenemos gestión de recursos compartidos, sino un diseño principalmente basado en código reentrante. Ahora bien, realmente no tenemos “recursos compartidos”? que pasa con la gestión de la base de datos?. Eso es algo que dejaremos para un futuro artículo…
Hoy en día mayormente programamos en lenguajes de alto nivel, los cuales tienen ventajas enormes en productividad, escondiendo la gran mayoría de complejidades en abstracciones y permitiéndonos centrarnos principalmente en la lógica de negocios. Pudiendo construir aplicaciones con mucha mas simplicidad, legibilidad y mantenibilidad.
Pero a pesar de ello, considero muy importante el conocer un poco mas a profundidad estas complejidades, y saber que realmente hace nuestro código. Por que de lo contrario, en muchos casos terminamos con soluciones inevitablemente mediocres, o eventualmente nos encontraremos con problemas que no sepamos resolver.