AulaML Python · C++
C++ avanzado · 50 min · C++ moderno

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.

Lección 3 de 3 del módulo
Al terminar esta lección podrás
  • 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
  1. ¿Qué es una excepción?
  2. ¿Para qué sirven las excepciones y los hilos?
  3. Cómo funciona por dentro: propagación y desenrollado
  4. Sintaxis: throw, try/catch, noexcept y RAII
  5. Ejemplo básico: capturar y continuar
  6. Ejemplo intermedio: dos hilos y un mutex
  7. Caso práctico: async, future y jthread
  8. Visualización: el viaje de una excepción
  9. Errores comunes
  10. Buenas prácticas
  11. Ejercicios
  12. Soluciones explicadas
  13. Resumen
  14. 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 pair con código y valor por cada llamada.
  • Liberar recursos con RAII: al desenrollar, los destructores de vector, string, unique_ptr y lock_guard se 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::jthread une automáticamente y std::async devuelve un future que transporta el resultado o la excepción.
MecanismoQué aportaCoste o riesgo
Código de retornoSimple y visible en la firmaSe puede ignorar; mezcla resultado y estado
ExcepciónPropagación automática y RAIICoste al lanzar; un catch mal puesto la oculta
std::threadControl directo del ciclo de vidaDebes unir o separar antes del destructor
std::jthreadUne solo y permite cancelar con stop_tokenRequiere C++20 y cooperación de la tarea
std::asyncDevuelve un future con el resultadoLa 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ó.

CPP excepcion.cpp
#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.

CPP raii.cpp
#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.

  1. dividir(6.0, 2.0) comprueba que el divisor no es cero y devuelve 3.
  2. dividir(1.0, 0.0) encuentra b == 0.0 y ejecuta throw con un std::invalid_argument.
  3. El try abandona la expresión y busca un catch compatible con ese tipo.
  4. El catch recibe la excepción por const&, lee what() y la imprime.
  5. 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.

CPP hilos.cpp
#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.

CPP async.cpp
#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.

CPP jthread.cpp
#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.

Ejecución visual ·Desenrollado hasta el catch
1 / 6
1 #include <iostream>
2 #include <stdexcept>
3
4 double dividir(double a, double b) {
5 if (b == 0.0) {
6 throw std::invalid_argument("division por cero");
7 }
8 return a / b;
9 }
10
11 int main() {
12 try {
13 double r = dividir(8.0, 0.0);
14 std::cout << r << '\n';
15 } catch (const std::invalid_argument& e) {
16 std::cout << "error: " << e.what() << '\n';
17 }
18 }
main llama a dividir(8.0, 0.0). Entra un nuevo frame con a = 8, b = 0; r aún no existe.
Pila de llamadas
main()
r = —
dividir(8.0, 0.0)()
a = 8b = 0
Variables
r = sin inicializar
Salida
—

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. Usa catch (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() o detach(): el destructor de un std::thread unible termina el proceso. Usa std::jthread o une en todos los caminos, incluido el de excepción.
  • Compartir una variable sin mutex: dos hilos escribiendo el mismo int producen una condición de carrera. El resultado puede cambiar entre ejecuciones y el estándar lo considera indefinido.
  • Marcar noexcept una función que lanza: la excepción no se puede capturar y el proceso termina. noexcept es 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.
Un hilo sin unir
Incorrecto
PYTHON Incorrecto: el destructor llama a terminate
void lanzar() {
    std::thread t([] { trabajo(); });
}   // t sigue unible al salir
Correcto
PYTHON Correcto: esperar o usar jthread
void 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 por const&.
  • Mantén el try lo más pequeño posible: cuanto menos código envuelvas, más claro es qué puede fallar.
  • Deja que RAII libere: usa std::lock_guard o std::scoped_lock en lugar de lock()/unlock() manual.
  • Prefiere std::jthread a std::thread en 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::async y future; para muchas tareas cortas, mide antes de crear un hilo por elemento.
Errores en Python y en C++
Python
PYTHON errores.py
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)
C++
CPP errores.cpp
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

Ejercicio 1

Raíz cuadrada con dominio validado

Escribe 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.

Ejercicio 2

Suma paralela con cuatro hilos

Suma los enteros de 1 a 1 000 000 usando cuatro hilos. Cada hilo debe calcular su parcial en una variable local y sumarlo a un 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.

Completar código Completa el manejo y el bloqueo
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

  • throw crea una excepción; catch la captura por referencia y what() describe el error.
  • Al lanzar, la pila se desenrolla y los destructores liberan los recursos: eso es RAII en acción.
  • noexcept promete no lanzar; si la promesa se rompe, el programa termina.
  • Un std::thread debe unirse o separarse antes de su destructor; std::jthread lo hace solo.
  • Un mutex y lock_guard convierten varias operaciones en una sección crítica.
  • Sin sincronización, dos hilos sobre el mismo dato producen una condición de carrera.
  • std::async y future transportan resultados y excepciones de vuelta al hilo que llama.

Evaluación

Evaluación 1

¿Por qué se captura con const std::exception& en lugar de std::exception?

Evaluación 2

¿Qué ocurre si un std::thread sigue unible cuando se destruye?

Evaluación 3

¿Qué protege exactamente std::lock_guard<std::mutex> bloqueo(m);?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.