Excepciones y std::thread
Informar de errores sin códigos de retorno y ejecutar trabajo en paralelo sin corromper datos
Una excepción separa el lugar donde falla algo del lugar que sabe recuperarse; un hilo permite que dos tareas avancen a la vez. Aquí verás cómo se propaga una excepción, cómo se libera un recurso al desenrollar la pila y cómo compartir datos entre hilos con un mutex.
- Lanzar y capturar excepciones estándar por referencia constante
- Explicar el desenrollado de pila y el papel de RAII al lanzar
- Crear hilos con std::thread y sincronizar accesos con std::mutex
- Reconocer una condición de carrera y evitarla con lock_guard
- Usar std::async, std::future y std::jthread para tareas y cancelación
Antes necesitas: RAII y punteros inteligentes · Move semantics, optional y variant
En esta lección
- ¿Qué es una excepción?
- ¿Para qué sirven las excepciones y los hilos?
- Cómo funciona por dentro: propagación y desenrollado
- Sintaxis: throw, try/catch, noexcept y RAII
- Ejemplo básico: capturar y continuar
- Ejemplo intermedio: dos hilos y un mutex
- Caso práctico: async, future y jthread
- Visualización: el viaje de una excepción
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- Evaluación
Un programa real falla: el archivo no existe, la red se corta, el usuario escribe texto donde esperabas un número. La pregunta no es si habrá errores, sino cómo se entera el código que puede reaccionar. C++ ofrece excepciones para ese aviso e hilos para repartir trabajo; ambas comparten un requisito: dejar los recursos coherentes.
Una excepción interrumpe el flujo normal y busca un catch que sepa manejarla. Un hilo ejecuta una función en paralelo. Si los combinas sin cuidado, un error puede terminar el proceso y dos hilos pueden escribir la misma variable a la vez.
¿Qué es una excepción?
Una excepción es un objeto que se crea con throw y viaja hacia arriba por la pila de llamadas hasta que alguien lo captura con catch. Mientras viaja, las variables locales de las funciones abandonadas se destruyen en orden inverso: eso es el desenrollado de pila (stack unwinding).
No es lo mismo que un return con código de error: un código puede ignorarse sin querer, mientras que una excepción no capturada termina el programa. A cambio, lanzar tiene coste. Úsalas para lo excepcional, no para el flujo normal.
¿Para qué sirven las excepciones y los hilos?
- Separar detección de manejo: la función que lee un archivo detecta el fallo; la capa que decide reintentar o avisar al usuario lo maneja.
- Propagar sin ensuciar firmas: no tienes que devolver un
paircon código y valor por cada llamada. - Liberar recursos con RAII: al desenrollar, los destructores de
vector,string,unique_ptrylock_guardse ejecutan igual. - Aprovechar varios núcleos: un hilo por tarea permite paralelizar sumas, predicciones o lecturas mientras el hilo principal responde.
- Modelar concurrencia estructurada:
std::jthreadune automáticamente ystd::asyncdevuelve unfutureque transporta el resultado o la excepción.
| Mecanismo | Qué aporta | Coste o riesgo |
|---|---|---|
| Código de retorno | Simple y visible en la firma | Se puede ignorar; mezcla resultado y estado |
| Excepción | Propagación automática y RAII | Coste al lanzar; un catch mal puesto la oculta |
std::thread | Control directo del ciclo de vida | Debes unir o separar antes del destructor |
std::jthread | Une solo y permite cancelar con stop_token | Requiere C++20 y cooperación de la tarea |
std::async | Devuelve un future con el resultado | La política de lanzamiento puede ser diferida |
Cómo funciona por dentro: propagación y desenrollado
Cuando ejecutas throw std::invalid_argument("mensaje"), el compilador crea una copia del objeto en una zona especial y comienza a salir de la función actual. Destruye sus locales, pasa a la llamante y repite. Si encuentra un try con un catch compatible, se detiene allí; si llega a main sin captura, llama a std::terminate.
Ese recorrido es la razón por la que RAII funciona con excepciones: no necesitas un bloque finally. Un std::lock_guard guarda el mutex en su constructor y lo libera en el destructor; si el cuerpo lanza, el destructor corre durante el desenrollado y el mutex no queda bloqueado para siempre.
Una función marcada noexcept promete no lanzar. Si a pesar de eso lanza, el programa llama a std::terminate inmediatamente; no hay catch que lo salve. Los constructores de movimiento y los destructores suelen ser noexcept porque los contenedores dependen de ello. Si un destructor lanza durante otro desenrollado, el resultado también es terminate.
Sintaxis: throw, try/catch, noexcept y RAII
El try encierra el código que puede fallar y cada catch declara el tipo que maneja. Captura por const&: por valor cortas el objeto y pierdes la parte derivada. catch (...) captura cualquier cosa, pero no dice qué pasó.
#include <iostream>
#include <stdexcept>
double dividir(double a, double b) {
if (b == 0.0) {
throw std::invalid_argument("division por cero");
}
return a / b;
}
int main() {
try {
std::cout << dividir(6.0, 2.0) << '\n'; // 3
std::cout << dividir(1.0, 0.0) << '\n'; // no se imprime
} catch (const std::invalid_argument& e) {
std::cout << "error: " << e.what() << '\n';
}
std::cout << "el programa continua\n";
return 0;
}Todas las excepciones estándar heredan de std::exception, que ofrece what(). Las más útiles son std::invalid_argument, std::out_of_range, std::runtime_error, std::domain_error y std::bad_alloc. Si defines una propia, hereda de std::runtime_error y pasa el mensaje al constructor base.
RAII significa que un objeto posee un recurso y lo libera en su destructor. Así se ve en la práctica: el recurso se abre en el constructor, el cuerpo puede lanzar y el destructor cierra pase lo que pase. No hay que escribir finally ni acordarse de liberar en cada return.
#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>
class Recurso {
public:
explicit Recurso(std::string nombre) : nombre_(std::move(nombre)) {
std::cout << "abrir " << nombre_ << '\n';
}
~Recurso() {
std::cout << "cerrar " << nombre_ << '\n';
}
private:
std::string nombre_;
};
void cargar(bool falla) {
Recurso r("archivo");
if (falla) {
throw std::runtime_error("no se pudo leer");
}
std::cout << "datos leidos\n";
}
int main() {
try {
cargar(true);
} catch (const std::exception& e) {
std::cout << "error: " << e.what() << '\n';
}
return 0;
}La salida es abrir archivo, cerrar archivo y error: no se pudo leer. El cierre aparece antes del mensaje porque el destructor se ejecuta al desenrollar: esa garantía hace seguros los recursos.
Ejemplo básico: capturar y continuar
El primer programa imprime 3, luego error: division por cero y termina con el programa continua. La segunda llamada nunca devuelve un valor: la excepción salta directamente al catch.
dividir(6.0, 2.0)comprueba que el divisor no es cero y devuelve 3.dividir(1.0, 0.0)encuentrab == 0.0y ejecutathrowcon unstd::invalid_argument.- El
tryabandona la expresión y busca uncatchcompatible con ese tipo. - El
catchrecibe la excepción porconst&, leewhat()y la imprime. - El flujo sigue después del bloque
try, no dentro de él: la línea que imprimiría el resultado no se ejecuta.
Ejemplo intermedio: dos hilos y un mutex
Un std::thread se construye con una función o un callable y empieza a ejecutarse de inmediato. join() espera a que termine; detach() lo deja suelto. Si destruyes un std::thread que sigue unible, el programa llama a std::terminate: el estándar no quiere adivinar si querías esperar o separar.
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
int main() {
long long total = 0;
std::mutex candado;
std::vector<std::thread> hilos;
for (int i = 0; i < 4; ++i) {
hilos.emplace_back([&total, &candado, i]() {
long long parcial = 0;
for (int k = 1; k <= 1000; ++k) {
parcial += i * 1000 + k;
}
std::lock_guard<std::mutex> bloqueo(candado);
total += parcial;
});
}
for (std::thread& h : hilos) {
h.join();
}
std::cout << total << '\n'; // 8002000
return 0;
}Cada hilo suma su bloque en una variable local parcial y solo toca total dentro del lock_guard. El orden en que los cuatro hilos toman el candado varía entre ejecuciones, pero el total impreso es siempre 8002000. Si quitaras el mutex, dos hilos podrían leer el mismo valor de total, sumarle su parcial y escribir uno encima del otro: una condición de carrera y comportamiento indefinido.
Caso práctico: async, future y jthread
std::async lanza una tarea y devuelve un std::future, un objeto que representa un resultado que todavía no está listo. get() espera y devuelve el valor; si la tarea lanzó una excepción, get() la vuelve a lanzar en el hilo que llama. Esa es la forma de transportar errores entre hilos sin compartir estado.
#include <future>
#include <iostream>
long long sumar_hasta(int n) {
long long total = 0;
for (int i = 1; i <= n; ++i) {
total += i;
}
return total;
}
int main() {
std::future<long long> fut = std::async(std::launch::async, sumar_hasta, 1000);
std::cout << "trabajando...\n";
std::cout << fut.get() << '\n'; // 500500
return 0;
}La política std::launch::async obliga a ejecutar en otro hilo. Sin ella, la implementación puede diferir la tarea hasta que llames a get(), y entonces el trabajo ocurriría en el mismo hilo. Para medir paralelismo real, sé explícito.
#include <iostream>
#include <stop_token>
#include <thread>
int main() {
std::jthread tarea([](std::stop_token token) {
for (int i = 1; i <= 3 && !token.stop_requested(); ++i) {
std::cout << "paso " << i << '\n';
}
});
tarea.join(); // espera a que termine
std::cout << "unido\n";
return 0;
}std::jthread es un hilo que se une solo al destruirse y que recibe un std::stop_token si la función lo declara. La tarea consulta stop_requested() para terminar de forma cooperativa. Aquí no se pide parada, así que la salida es siempre paso 1, paso 2, paso 3 y unido; con request_stop() inmediato, el número de pasos variaría según cuándo arranque el hilo.
Visualización: el viaje de una excepción
Sigue la excepción desde la línea que la lanza hasta el catch. Observa que r nunca llega a inicializarse y que la función dividir no devuelve nada en ese camino.
#include <iostream>#include <stdexcept>double dividir(double a, double b) { if (b == 0.0) { throw std::invalid_argument("division por cero"); } return a / b;}int main() { try { double r = dividir(8.0, 0.0); std::cout << r << '\n'; } catch (const std::invalid_argument& e) { std::cout << "error: " << e.what() << '\n'; }}main llama a dividir(8.0, 0.0). Entra un nuevo frame con a = 8, b = 0; r aún no existe.—
Avanza paso a paso: observa la línea resaltada, las variables y la salida.
Errores comunes
- Capturar por valor:
catch (std::exception e)corta el objeto y pierde la parte derivada. Usacatch (const std::exception& e). - Tragarse todo con
catch (...): el programa sigue como si nada y el fallo reaparece más tarde. Captura solo lo que sepas manejar y deja subir el resto. - Lanzar desde un destructor: si ya hay otra excepción en vuelo, el programa llama a
std::terminate. Los destructores no deben lanzar. - Olvidar
join()odetach(): el destructor de unstd::threadunible termina el proceso. Usastd::jthreado une en todos los caminos, incluido el de excepción. - Compartir una variable sin mutex: dos hilos escribiendo el mismo
intproducen una condición de carrera. El resultado puede cambiar entre ejecuciones y el estándar lo considera indefinido. - Marcar
noexceptuna función que lanza: la excepción no se puede capturar y el proceso termina.noexceptes una promesa, no una sugerencia. - Pensar que el orden de ejecución es fijo: los hilos se intercalan de forma distinta en cada ejecución. Sincroniza el resultado, no el orden de las impresiones.
void lanzar() {
std::thread t([] { trabajo(); });
} // t sigue unible al salirvoid lanzar() {
std::jthread t([] { trabajo(); });
} // pide parar y une al salir`std::thread` no decide por ti entre esperar y separar, así que su destructor aborta si el hilo sigue unible. `std::jthread` une automáticamente y además ofrece cancelación cooperativa con `stop_token`.
Buenas prácticas
- Lanza tipos con significado (
std::invalid_argument,std::runtime_error) y captura porconst&. - Mantén el
trylo más pequeño posible: cuanto menos código envuelvas, más claro es qué puede fallar. - Deja que RAII libere: usa
std::lock_guardostd::scoped_locken lugar delock()/unlock()manual. - Prefiere
std::jthreadastd::threaden código nuevo; el destructor seguro evita terminaciones accidentales. - Protege cada dato compartido con un mutex o evita compartirlo: calcula en local y combina al final.
- Documenta si una función es
noexcept; no lo pongas por costumbre si el cuerpo puede lanzar. - Para tareas con resultado, usa
std::asyncyfuture; para muchas tareas cortas, mide antes de crear un hilo por elemento.
def dividir(a, b):
if b == 0:
raise ValueError("division por cero")
return a / b
try:
print(dividir(1, 0))
except ValueError as e:
print("error:", e)double dividir(double a, double b) {
if (b == 0.0) throw std::invalid_argument("division por cero");
return a / b;
}
try {
std::cout << dividir(1.0, 0.0);
} catch (const std::invalid_argument& e) {
std::cout << "error: " << e.what();
}La estructura es casi idéntica, pero C++ obliga a declarar el tipo en el `catch` y a capturar por referencia para no copiar. En Python cualquier excepción deriva de `BaseException`; en C++ solo las que heredan de `std::exception` ofrecen `what()`.
Ejercicios
Raíz cuadrada con dominio validado
double raiz(double x) que lance std::domain_error si x < 0 y devuelva std::sqrt(x) en caso contrario. En main, prueba con 16 y con -4 dentro de un try, captura const std::exception& e imprime what(). Añade una función auxiliar bool es_no_negativo(double) noexcept.Pista
Incluye <cmath> y <stdexcept>. std::sqrt de un negativo devuelve NaN, así que la comprobación debe hacerse antes.
Suma paralela con cuatro hilos
long long compartido dentro de un std::lock_guard. Une todos los hilos y muestra el total, que debe ser 500000500000.Pista
Divide el rango en cuatro bloques de 250 000. Pasa inicio y fin por valor al lambda y captura total y el mutex por referencia.
try { lanzar(); } (const std::exception& e) { std::cout << e.(); } std::mutex m; std::<std::mutex> bloqueo(m);
La palabra que captura es la pareja de `try`; el método que devuelve el mensaje está en `std::exception`; el guardián de alcance se llama `lock_guard`.
Soluciones explicadas
En el primer ejercicio, la validación ocurre antes del cálculo. Si llamaras a std::sqrt(-4.0) sin comprobar, obtendrías NaN y el error se propagaría en silencio hasta una comparación o una impresión. Lanzar std::domain_error convierte un valor inválido en una señal explícita que el llamador puede manejar o dejar subir.
En el segundo, el reparto del rango es lo que garantiza el resultado: los bloques 1–250000, 250001–500000, 500001–750000 y 750001–1000000 son disjuntos y cubren todo. El mutex no hace que la suma sea más rápida; hace que sea correcta. El tiempo no baja a la cuarta parte: crear hilos y sincronizar tiene coste, y para una suma simple el hilo principal puede ser igual de rápido.
Resumen
throwcrea una excepción;catchla captura por referencia ywhat()describe el error.- Al lanzar, la pila se desenrolla y los destructores liberan los recursos: eso es RAII en acción.
noexceptpromete no lanzar; si la promesa se rompe, el programa termina.- Un
std::threaddebe unirse o separarse antes de su destructor;std::jthreadlo hace solo. - Un mutex y
lock_guardconvierten varias operaciones en una sección crítica. - Sin sincronización, dos hilos sobre el mismo dato producen una condición de carrera.
std::asyncyfuturetransportan resultados y excepciones de vuelta al hilo que llama.
Evaluación
¿Por qué se captura con const std::exception& en lugar de std::exception?
¿Qué ocurre si un std::thread sigue unible cuando se destruye?
¿Qué protege exactamente std::lock_guard<std::mutex> bloqueo(m);?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.