Skip to main content

Command Palette

Search for a command to run...

C++ Multithreading desde cero — Parte 1

Qué es la concurrencia y cuándo usarla

Updated
10 min read
C++ Multithreading desde cero  — Parte 1

C++ siempre ha tenido esa fama de ser un lenguaje complejo, y la verdad es que no es un mito. Es un lenguaje amplio, profundo y con muchos matices que requieren una enorme cantidad de tiempo para dominar. Llevo casi dos años programando en C++, y durante ese tiempo había evitado el tema del multihilo. La primera vez que intenté estudiarlo apenas estaba comenzando mi recorrido con el lenguaje, y la complejidad del tema me sobrepasó, al punto de frustrarme y alejarme de él, Desde entonces lo había evitado, pero sabía que tarde o temprano llegaría el momento de enfrentarlo.

Recientemente estuve desarrollando un plugin para Photoshop, implementado de forma completamente secuencial. Sin embargo, el tiempo de ejecución resultaba demasiado lento, y pronto me di cuenta de que la tarea podía dividirse fácilmente en bloques independientes, lo que la convertía en un algoritmo altamente paralelizable.

Me decidí a tomar un curso, ver algunos tutoriales y revisar ejemplos de código. Aunque logré aprender ciertos conceptos, no puedo decir que los comprendi en profundidad. Por eso opté por empezar de nuevo, esta vez con calma, yendo paso a paso desde el principio. Elegí leer el libro C++ Concurrency in Action, que muchos consideran el mejor material para aprender de forma sólida y profunda sobre la programación multihilo en C++.

Cover of "C++ Concurrency in Action," 2nd Edition by Anthony Williams. Features an illustration of a person in historical attire against a brown background, with the Manning logo.

He leído varios capítulos del libro y he afianzado mucho más mi comprensión del tema. Me ha fascinado el nivel de conocimiento y detalle que requiere desarrollar aplicaciones de calidad que aprovechen el paralelismo y la concurrencia de forma correcta.

Por eso, esta serie de artículos estará dedicada a explorar los temas tratados en el libro, explicando sus ejemplos y acompañándolos con aplicaciones propias que iré desarrollando a lo largo del proceso.


1. ¿Qué es la concurrencia?

En su forma más básica, la concurrencia es la capacidad de realizar múltiples actividades al mismo tiempo. No se trata únicamente de ejecutar instrucciones en paralelo, sino de organizar el trabajo de manera que varias tareas progresen de forma independiente, solapando su ejecución en el tiempo. Este concepto no es exclusivo de la computación: en la vida diaria también actuamos concurrentemente. Podemos caminar y hablar a la vez, escribir con una mano mientras sostenemos un objeto con la otra o realizar distintas tareas en paralelo con personas diferentes.

En programación, la concurrencia busca aprovechar esta idea para aumentar la eficiencia y la capacidad de respuesta de los programas, especialmente en sistemas modernos donde los procesadores disponen de múltiples núcleos. Sin embargo, concurrencia no significa necesariamente paralelismo: un programa concurrente puede manejar varias tareas en progreso, aunque el hardware ejecute solo una a la vez; en cambio, el paralelismo implica que las tareas realmente se ejecutan simultáneamente en distintos núcleos o procesadores.


Enfoques de la concurrencia

Existen dos formas principales de implementar concurrencia en una aplicación:
usando múltiples procesos o usando múltiples hilos dentro de un mismo proceso. Aunque ambos enfoques persiguen el mismo objetivo —permitir que varias tareas avancen de forma independiente—, difieren en la manera en que gestionan los recursos y se comunican.

Concurrencia con múltiples procesos

En este modelo, la aplicación se divide en varios procesos independientes, cada uno con su propio espacio de memoria y su propio flujo de ejecución.
Cada proceso se comporta como una entidad aislada: si uno falla, no afecta directamente a los demás, y la comunicación entre ellos se realiza mediante mecanismos de comunicación entre procesos (IPC), como tuberías (pipes), sockets, archivos compartidos o señales del sistema operativo.

Este enfoque tiene ventajas importantes:

  • Mayor aislamiento y seguridad. Los procesos están protegidos entre sí, lo que reduce el riesgo de errores de memoria o corrupción de datos compartidos.

  • Mayor estabilidad. Si un proceso se bloquea, los demás pueden continuar funcionando sin problema.

  • Escalabilidad. Es posible distribuir los procesos entre distintos equipos conectados por red, aumentando así la capacidad de procesamiento del sistema.

Sin embargo, también presenta desventajas:

  • Comunicación más costosa. Los datos deben copiarse entre espacios de memoria separados, lo que añade latencia.

  • Mayor sobrecarga del sistema. Crear y gestionar varios procesos consume más recursos del sistema operativo.

  • Dificultad de sincronización. Coordinar el trabajo entre procesos puede requerir estructuras de comunicación complejas.

A pesar de estos inconvenientes, muchos lenguajes y sistemas distribuidos (como Erlang) usan este modelo por su fiabilidad y facilidad para construir aplicaciones robustas.

Concurrencia con múltiples hilos

El segundo enfoque —y el más utilizado en C++— es la concurrencia basada en hilos. Aquí, una única aplicación (un solo proceso) puede contener varios hilos de ejecución que comparten el mismo espacio de memoria. Cada hilo se comporta como un flujo independiente, capaz de ejecutar una parte distinta del programa, pero todos tienen acceso directo a las mismas variables y recursos.

Esto hace que la comunicación entre hilos sea mucho más rápida y eficiente, ya que los datos pueden compartirse directamente sin mecanismos externos. Sin embargo, esta facilidad trae consigo nuevos desafíos: si varios hilos acceden a la misma variable al mismo tiempo, pueden producirse condiciones de carrera o inconsistencias en la memoria.

Por ello, el programador debe garantizar manualmente la sincronización del acceso a los datos compartidos mediante mutexes, bloqueos u otros mecanismos de control.

Las principales ventajas de este enfoque son:

  • Bajo costo de creación y comunicación. Los hilos son más livianos que los procesos.

  • Mayor eficiencia. Permiten aprovechar al máximo los procesadores multinúcleo.

  • Integración directa con C++. Desde C++11, la biblioteca estándar incluye soporte nativo para hilos (std::thread) y sincronización.

Pero también tiene desventajas notables:

  • Mayor complejidad. Es necesario diseñar cuidadosamente cómo se comparten y modifican los datos.

  • Riesgo de errores difíciles de depurar. Problemas como bloqueos mutuos o interferencias entre hilos pueden ser difíciles de reproducir y corregir.

Por estas razones, el modelo multihilo es más rápido pero también más exigente. Requiere disciplina, conocimiento del modelo de memoria y el uso adecuado de las herramientas de sincronización.

En esta serie de artículos me enfocaré principalmente en este segundo enfoque —la concurrencia mediante múltiples hilos—, siguiendo las bases teóricas y prácticas que plantea el libro C++ Concurrency in Action.


2. Concurrencia vs Paralelismo

Aunque los términos concurrencia y paralelismo suelen usarse indistintamente, en el contexto de la programación multihilo existe una distinción conceptual importante entre ambos. Ambos se refieren a la ejecución simultánea de múltiples tareas, pero difieren en su propósito y enfoque.

La concurrencia se centra en la estructura y la organización del software. Su objetivo principal es permitir que diferentes tareas avancen de manera independiente, incluso si no se ejecutan estrictamente al mismo tiempo. Es una herramienta para manejar múltiples actividades que comparten recursos o requieren coordinación, buscando mejorar la responsividad y la separación de responsabilidades dentro de una aplicación.

Por otro lado, el paralelismo es una cuestión de rendimiento. Se trata de aprovechar el hardware disponible —como múltiples núcleos de CPU— para realizar varios cálculos en simultáneo. Su propósito es claro: reducir el tiempo total de ejecución de una tarea o aumentar el volumen de trabajo que puede procesarse en un mismo intervalo de tiempo. En pocas palabras, mientras la concurrencia busca organizar mejor el trabajo, el paralelismo busca hacerlo más rápido.

Esta diferencia de enfoque implica que no toda aplicación concurrente es necesariamente paralela. Una interfaz gráfica que mantiene su fluidez mientras realiza operaciones en segundo plano es concurrente, aunque no necesariamente paralela. En cambio, un programa que divide un conjunto de datos entre varios hilos para procesarlos por separado es claramente un caso de paralelismo.


3. ¿Por qué usar concurrencia?

Existen dos razones fundamentales para utilizar concurrencia en una aplicación: separación de responsabilidades y rendimiento. Ambas son pilares del diseño moderno de software y reflejan diferentes motivaciones detrás del uso de múltiples hilos o procesos.

Separación de responsabilidades

Una de las mejores prácticas en ingeniería de software es dividir el sistema en componentes bien delimitados. La concurrencia permite llevar esta idea un paso más allá: no solo separar el código, sino también permitir que distintas partes de una aplicación se ejecuten de forma independiente.

Imaginemos una aplicación como un reproductor de DVD. Este debe, por un lado, leer los datos del disco, decodificar el video y el audio, y enviarlos al hardware sin interrupciones; y por otro lado, debe responder a las acciones del usuario —como pausar o detener la reproducción— sin retrasos. Si todo se ejecutara en un solo hilo, el programa tendría que alternar manualmente entre tareas, complicando el diseño y reduciendo la fluidez de la interfaz.

La solución natural es utilizar hilos separados: uno para la reproducción y otro para la interfaz de usuario. De este modo, cada componente puede concentrarse en su función principal, y la interacción entre ambos ocurre solo en puntos bien definidos, como al recibir una orden de pausa. Este enfoque mejora la organización interna del código y crea una sensación de responsividad inmediata, incluso cuando el sistema está ocupado realizando tareas intensivas.

Concurrencia para mejorar el rendimiento

Durante décadas, el aumento del rendimiento en software venía “gratis”: los procesadores mejoraban su velocidad con cada nueva generación, y los programas se volvían automáticamente más rápidos. Sin embargo, esta tendencia llegó a su límite físico y térmico. Los fabricantes cambiaron de estrategia: en lugar de aumentar la frecuencia de reloj, comenzaron a incorporar múltiples núcleos en un mismo chip.

El resultado es que ahora la única forma de aprovechar plenamente la capacidad de cómputo de una máquina moderna es mediante la concurrencia. Para que una aplicación se beneficie de un procesador multinúcleo, debe ser capaz de dividir su trabajo en tareas que puedan ejecutarse simultáneamente.

Existen dos enfoques principales para lograrlo:

  • Paralelismo de tareas (Task Parallelism): consiste en dividir una tarea compleja en subtareas independientes que pueden ejecutarse al mismo tiempo. Por ejemplo, distintos hilos podrían encargarse de etapas separadas de un algoritmo o de diferentes componentes de un sistema.

  • Paralelismo de datos (Data Parallelism): implica aplicar la misma operación sobre diferentes partes de un conjunto de datos. Un ejemplo clásico es el procesamiento de imágenes, donde varios hilos pueden aplicar el mismo filtro sobre diferentes regiones de la imagen.

Los algoritmos que se prestan fácilmente a esta división se denominan embarrassingly parallel, un término que resalta lo sencillo que resulta paralelizarlos. Este tipo de algoritmos escalan muy bien, ya que pueden aprovechar un número creciente de núcleos para reducir proporcionalmente su tiempo de ejecución.

Finalmente, el paralelismo no siempre se usa para reducir el tiempo de procesamiento de una sola tarea: también puede servir para aumentar el volumen de trabajo procesado en paralelo. Por ejemplo, un programa puede procesar varias imágenes o archivos simultáneamente, aumentando así su rendimiento global o throughput.


4. Cuándo no usar concurrencia

Tan importante como saber cuándo aplicar concurrencia es reconocer cuándo no hacerlo. A pesar de sus beneficios, la concurrencia introduce una capa adicional de complejidad que puede volverse contraproducente si el problema no lo justifica.

La razón fundamental para evitar la concurrencia es simple: cuando el beneficio no compensa el costo. Escribir código concurrente suele ser más difícil de entender, probar y mantener. La interacción entre múltiples hilos puede generar errores sutiles, como condiciones de carrera, bloqueos mutuos (deadlocks) o inconsistencias de memoria. Estos problemas no solo consumen tiempo de desarrollo, sino que también aumentan el riesgo de fallos en producción.

Además del costo cognitivo, la concurrencia implica costos de rendimiento. Cada hilo consume recursos del sistema operativo —memoria para su pila, estructuras de control y tiempo de planificación—, por lo que crearlo y sincronizarlo nunca es gratuito. Si la tarea es demasiado breve, ese overhead puede anular cualquier ganancia esperada, e incluso empeorar el rendimiento. A esto se suma que los hilos son un recurso limitado: lanzar demasiados puede saturar el sistema, consumir memoria en exceso o degradar la eficiencia por un exceso de context switching. Cada cambio de contexto implica guardar y restaurar estados del procesador, una operación costosa que se multiplica cuando el número de hilos activos supera la cantidad de núcleos disponibles.

Por estas razones, la concurrencia como estrategia de optimización debe aplicarse con el mismo criterio que cualquier otra técnica de alto rendimiento: solo vale la pena cuando los beneficios son medibles y compensan la complejidad añadida. En muchos casos, optar por un enfoque secuencial o asincrónico ofrece un mejor equilibrio entre claridad, mantenibilidad y eficiencia.

Sin embargo, si el diseño requiere separación de responsabilidades —por ejemplo, para mantener una interfaz fluida mientras se ejecutan tareas de fondo— o si el rendimiento depende directamente del aprovechamiento del hardware disponible, entonces la concurrencia se convierte en una herramienta valiosa y necesaria. La clave está en usarla con criterio y propósito, no por simple moda o entusiasmo técnico.
Una idea que resume bien esta reflexión aparece en la charla “Multithreading is the answer. What is the question?” de Ansel Sermersheim (CppCon 2017), donde se enfatiza que aplicar multithreading sin un propósito claro suele generar más problemas que beneficios. En otras palabras, la concurrencia no debe ser la respuesta automática a todo desafío de rendimiento, sino una decisión técnica fundamentada en la naturaleza del problema.

Cplusplus

Part 3 of 3

Serie de artículos dedicada a la programación en C++, donde se explican desde los fundamentos del lenguaje hasta técnicas avanzadas, con ejemplos claros y orientados a la práctica

Start from the beginning

C++ Multithreading desde cero — Parte 3

Argumentos, ownership y control práctico de hilos en C++

More from this blog