C++ Multithreading desde cero — Parte 3
Argumentos, ownership y control práctico de hilos en C++

La base del trabajo con hilos ya está establecida: crear un hilo, controlar su finalización y mantener su ejecución dentro de un marco seguro. A partir de aquí, la concurrencia deja de ser solo una cuestión de lanzar tareas en paralelo y empieza a depender de cómo se comunican esas tareas, cómo comparten datos y cómo se transfiere la responsabilidad sobre cada hilo.
Esta sección se centra en esos aspectos prácticos. Se examina el paso de argumentos, la propiedad de los hilos y la organización de múltiples hilos en ejecución. Cada uno de estos elementos define la manera en que un programa concurrente distribuye su trabajo y aprovecha realmente los recursos del sistema.
Pasar argumentos a una función de hilo
Cuando creamos un hilo en C++ con std::thread, además de especificar la función que ejecutará, también podemos pasarle argumentos. Esto se realiza directamente en el constructor del hilo, como si estuviéramos llamando a la función:
void f(int i, std::string const& s);
int main() {
std::thread t(f, 3, "hello");
t.join();
}
En este caso, el hilo ejecutará la llamada f(3, "hello"). Sin embargo, aunque parezca una llamada directa, internamente el constructor de std::thread copia los argumentos a un almacenamiento interno, y luego los pasa a la función como valores temporales (rvalues) dentro del nuevo hilo de ejecución.
Esto tiene varias consecuencias importantes.
Copias internas y conversiones tardías
Los argumentos se copian tal cual son proporcionados, antes de que ocurra cualquier conversión de tipo esperada por la función.
Por ejemplo, en el siguiente código:
void f(int i, std::string const& s);
void oops(int some_param) {
char buffer[1024];
sprintf(buffer, "%i", some_param);
std::thread t(f, 3, buffer); // <- Peligroso
t.detach();
}
Aquí buffer es un arreglo local, y lo que realmente se pasa al hilo es un puntero (char*). El constructor de std::thread copia ese puntero sin realizar la conversión a std::string, porque esa conversión ocurre más tarde, cuando el hilo comienza su ejecución.
El problema es que, para cuando el nuevo hilo intenta hacer la conversión, buffer podría haber dejado de existir, produciendo comportamiento indefinido.
La forma correcta es convertir explícitamente a std::string antes de pasar el argumento:
void not_oops(int some_param) {
char buffer[1024];
sprintf(buffer, "%i", some_param);
std::thread t(f, 3, std::string(buffer)); // <- Correcto
t.detach();
}
En este caso, la conversión a std::string ocurre en el hilo principal, de modo que lo que se copia internamente es un objeto completamente válido e independiente.
Paso por referencia: std::ref y std::cref
De manera predeterminada, std::thread copia todos los argumentos, incluso si la función espera una referencia.
Esto significa que el siguiente código no compilará:
void update_data_for_widget(widget_id w, widget_data& data);
void oops_again(widget_id w) {
widget_data data;
std::thread t(update_data_for_widget, w, data); // <- Error
t.join();
}
Aquí, update_data_for_widget espera una referencia, pero std::thread intenta pasar una copia de data como si fuera un rvalue, lo cual no es válido para una referencia no constante.
Para indicar explícitamente que queremos pasar una referencia, debemos envolver el argumento con std::ref (o std::cref si es una referencia constante):
std::thread t(update_data_for_widget, w, std::ref(data));
Ahora el hilo recibirá una referencia real a data, y la función podrá modificarla correctamente.
Paso de objetos no copiables (uso de std::move)
Existen tipos que no pueden copiarse, como std::unique_ptr, pero que sí pueden moverse. En estos casos, es necesario usar std::move para transferir la propiedad del objeto al hilo:
void process_big_object(std::unique_ptr<big_object> ptr);
int main() {
std::unique_ptr<big_object> p(new big_object);
p->prepare_data(42);
std::thread t(process_big_object, std::move(p));
t.join();
}
Aquí, la propiedad del puntero se transfiere al hilo, y el objeto p en el hilo principal queda vacío. Este enfoque es muy útil cuando se desea pasar recursos dinámicos de forma segura y eficiente.
Funciones miembro y lambdas
std::thread también puede ejecutar funciones miembro de una clase. En ese caso, el primer argumento debe ser el puntero al objeto sobre el cual se invocará el método:
class X {
public:
void do_lengthy_work() {
std::cout << "Ejecutando trabajo largo...\n";
}
};
int main() {
X x;
std::thread t(&X::do_lengthy_work, &x);
t.join();
}
Esto ejecutará x.do_lengthy_work() en el nuevo hilo.
De manera análoga, es posible usar lambdas para encapsular tanto la función como los argumentos:
int main() {
int value = 10;
std::thread t([value]() {
std::cout << "Valor: " << value << '\n';
});
t.join();
}
Las lambdas capturan los valores según sus reglas ([value], [&value], [=], [&]), lo que permite controlar explícitamente si se copian o se referencian los datos.
Consideraciones sobre el tiempo de vida
Es fundamental garantizar que los objetos referenciados por el hilo permanezcan válidos mientras el hilo los use.
Esto implica:
Si pasas referencias (con
std::ref), asegúrate de que el objeto exista al menos hasta que el hilo finalice.Si pasas punteros, evita apuntar a variables automáticas que puedan salir de alcance.
Si usas
detach(), ten cuidado: el hilo puede seguir ejecutándose después de que el contexto local haya terminado.
En general, es más seguro pasar copias o usar std::shared_ptr si el objeto necesita sobrevivir más allá del alcance del hilo que lo creó.
Transferir la propiedad de un hilo
Cada objeto std::thread posee un hilo de ejecución: es el responsable de gestionar su ciclo de vida, ya sea esperando su finalización mediante join() o liberándolo con detach().
Esta relación exclusiva implica que solo un objeto std::thread puede poseer un hilo determinado a la vez.
A diferencia de otros tipos copiables, los hilos no pueden duplicarse, porque esto implicaría que dos objetos intentaran controlar el mismo recurso del sistema operativo. Sin embargo, sí pueden transferirse entre objetos mediante move semantics —el mismo mecanismo que utilizan clases como std::unique_ptr para transferir propiedad de recursos únicos.
Propiedad y movimiento de hilos
El siguiente ejemplo muestra cómo puede moverse la propiedad de un hilo entre distintos objetos std::thread:
void some_function();
void some_other_function();
int main() {
std::thread t1(some_function); // t1 posee el hilo
std::thread t2 = std::move(t1); // t2 toma la propiedad
t1 = std::thread(some_other_function); // t1 crea y posee un nuevo hilo
std::thread t3; // hilo vacío
t3 = std::move(t2); // t3 toma el hilo original
t1 = std::move(t3); // ¡Error! std::terminate()
}
El flujo de propiedad es el siguiente:
t1crea un hilo que ejecutasome_function.t2 = std::move(t1)transfiere la propiedad del hilo det1at2.
Después de esto,t1queda sin hilo asociado.t1 = std::thread(some_other_function)inicia un nuevo hilo y se convierte en su dueño.t3toma el hilo det2constd::move(t2).Finalmente, la reasignación
t1 = std::move(t3)provoca la terminación del programa, ya quet1aún era dueño de un hilo no finalizado.
Este último punto es importante: asignar un nuevo hilo a un objeto que ya posee uno activo invoca std::terminate().
La norma impone esto para mantener la consistencia con el destructor de std::thread, que también requiere que el hilo se haya sincronizado o desacoplado antes de destruir el objeto.
Transferencia de hilos entre funciones
El soporte de movimiento en std::thread permite devolver o recibir hilos por valor en funciones, algo muy útil para diseñar interfaces limpias y seguras.
std::thread create_thread() {
return std::thread([] {
std::cout << "Ejecutando hilo...\n";
});
}
void consume_thread(std::thread t) {
if (t.joinable()) t.join();
}
int main() {
std::thread t = create_thread(); // El hilo se transfiere por retorno
consume_thread(std::move(t)); // Se pasa la propiedad a la función
}
En este ejemplo:
create_thread()devuelve unstd::threadmovido, que transfiere su propiedad al llamador.consume_thread()acepta el hilo por valor, y puede unirse a él sin preocuparse de interferir con otros dueños.
La transferencia explícita mediante std::move() evita copias ilegales y garantiza que solo exista un dueño válido del hilo en cada momento.
Clases auxiliares: scoped_thread y joining_thread
A menudo es conveniente encapsular la gestión del hilo dentro de un objeto que asegure la sincronización automática al salir del ámbito. Una forma sencilla de hacerlo es con una clase llamada scoped_thread, que toma la propiedad de un hilo en su constructor y lo une en su destructor:
class scoped_thread {
std::thread t;
public:
explicit scoped_thread(std::thread t_) : t(std::move(t_)) {
if (!t.joinable())
throw std::logic_error("No thread");
}
~scoped_thread() {
t.join();
}
scoped_thread(const scoped_thread&) = delete;
scoped_thread& operator=(const scoped_thread&) = delete;
};
Su uso es simple y seguro:
void task();
int main() {
scoped_thread worker(std::thread(task)); // El hilo se une automáticamente al salir
}
Gracias a este patrón, se evita olvidar la llamada a join(), reduciendo el riesgo de errores y cierres abruptos del programa.
Una variante más flexible es la clase joining_thread, que se comporta como un std::thread estándar pero se une automáticamente en su destructor. Esto permite usarla de forma más natural en estructuras dinámicas o funciones que retornan hilos
class joining_thread {
std::thread t;
public:
joining_thread() noexcept = default;
template <typename Callable, typename... Args>
explicit joining_thread(Callable&& f, Args&&... args)
: t(std::forward<Callable>(f), std::forward<Args>(args)...) {}
joining_thread(joining_thread&& other) noexcept
: t(std::move(other.t)) {}
joining_thread& operator=(joining_thread&& other) noexcept {
if (joinable()) join();
t = std::move(other.t);
return *this;
}
~joining_thread() noexcept {
if (joinable()) join();
}
bool joinable() const noexcept { return t.joinable(); }
void join() { t.join(); }
void detach() { t.detach(); }
};
De esta forma, joining_thread combina la seguridad de scoped_thread con la flexibilidad de std::thread.
Contenedores de hilos
El soporte de movimiento también permite almacenar hilos en contenedores dinámicos como std::vector.
Esto resulta útil para lanzar múltiples tareas y luego sincronizarlas en grupo:
void do_work(unsigned id) {
std::cout << "Trabajando en hilo " << id << "\n";
}
int main() {
std::vector<std::thread> threads;
for (unsigned i = 0; i < 8; ++i)
threads.emplace_back(do_work, i);
for (auto& t : threads)
if (t.joinable()) t.join();
}
Cada hilo se crea y se almacena dentro del vector mediante movimiento implícito, y luego todos son sincronizados al final del programa.
Esta técnica permite administrar un número variable de hilos sin declarar múltiples variables, facilitando la creación de thread pools o sistemas de tareas paralelas.
Elegir el número de hilos en tiempo de ejecución
Determinar cuántos hilos crear en un programa concurrente es una de las decisiones más importantes al diseñar una aplicación paralela eficiente. Crear demasiados hilos puede saturar el sistema operativo con cambios de contexto innecesarios; crear muy pocos desaprovecha el potencial de paralelismo del hardware.
Por ello, el número de hilos óptimo debe elegirse en tiempo de ejecución, tomando en cuenta los recursos físicos disponibles y el tipo de carga de trabajo.
Consultar el hardware: std::thread::hardware_concurrency()
La biblioteca estándar de C++ proporciona una función que sirve como guía inicial para decidir cuántos hilos pueden ejecutarse realmente de forma concurrente:
unsigned int n = std::thread::hardware_concurrency();
Esta función devuelve el número de hardware threads (generalmente, el número de núcleos o núcleos lógicos) disponibles para el programa.
Por ejemplo, en un CPU con cuatro núcleos físicos y hyper-threading, el valor devuelto podría ser 8.
Sin embargo, es importante entender que este valor es solo una sugerencia.
La implementación puede devolver 0 si la información no está disponible, por lo que es buena práctica definir un valor por defecto:
unsigned int num_threads = std::thread::hardware_concurrency();
if (num_threads == 0)
num_threads = 2; // valor por defecto razonable
Este número sirve como punto de partida para asignar trabajo entre hilos, pero no siempre representa la cantidad ideal: en tareas muy ligeras o con fuerte interacción entre hilos, crear un hilo por núcleo puede no ser lo más eficiente.
Estrategia básica: dividir la carga de trabajo
Una forma sencilla de aplicar este principio es dividir un conjunto de datos entre varios hilos.
El ejemplo siguiente implementa una versión paralela del algoritmo std::accumulate, que suma los elementos de un rango, dividiéndolos entre varios hilos según la cantidad de núcleos disponibles.
accumulate_CD_04
template <typename Iterator, typename T>
struct accumulate_block {
void operator()(Iterator first, Iterator last, T& result) {
result = std::accumulate(first, last, result);
}
};
template <typename Iterator, typename T>
T parallel_accumulate(Iterator first, Iterator last, T init) {
unsigned long const length = std::distance(first, last);
if (!length)
return init;
unsigned long const min_per_thread = 25;
unsigned long const max_threads =
(length + min_per_thread - 1) / min_per_thread;
unsigned long const hardware_threads =
std::thread::hardware_concurrency();
unsigned long const num_threads =
std::min(hardware_threads != 0 ? hardware_threads : 2, max_threads);
unsigned long const block_size = length / num_threads;
std::vector<T> results(num_threads);
std::vector<std::thread> threads(num_threads - 1);
Iterator block_start = first;
for (unsigned long i = 0; i < (num_threads - 1); ++i) {
Iterator block_end = block_start;
std::advance(block_end, block_size);
threads[i] = std::thread(
accumulate_block<Iterator, T>(),
block_start, block_end, std::ref(results[i])
);
block_start = block_end;
}
accumulate_block<Iterator, T>()(
block_start, last, results[num_threads - 1]
);
for (auto& t : threads)
t.join();
return std::accumulate(results.begin(), results.end(), init);
}
Análisis del algoritmo
Verificación del tamaño de entrada
Si el rango está vacío, simplemente se devuelve el valor inicialinit.
Esto evita lanzar hilos innecesarios cuando no hay trabajo que hacer.Límite mínimo por hilo
Se define un número mínimo de elementos por hilo (min_per_thread), con el fin de evitar la sobrecarga que supondría crear muchos hilos para tareas pequeñas.Cálculo del número máximo de hilos
A partir del tamaño del rango y del mínimo por hilo, se obtiene el número máximo de hilos que tendría sentido crear.Número real de hilos a usar
Se elige el menor valor entre el número máximo calculado y el número de hilos de hardware disponibles. Además, si la consulta al hardware falla, se usa un valor por defecto (en este caso, 2).División del trabajo
El tamaño del bloque asignado a cada hilo se calcula dividiendo la longitud total entre el número de hilos (block_size).Creación y ejecución de hilos
Se lanzannum_threads - 1hilos, cada uno procesando una parte del rango.
El hilo principal procesa el último bloque para evitar crear un hilo adicional.Sincronización final
Todos los hilos creados se sincronizan mediantejoin(), y luego se combinan los resultados parciales con un últimostd::accumulate.
Consideraciones sobre rendimiento
El objetivo de este enfoque es maximizar la utilización del hardware evitando el fenómeno de oversubscription, que ocurre cuando hay más hilos activos que núcleos disponibles. El exceso de hilos genera cambios de contexto constantes, lo que degrada el rendimiento en lugar de mejorarlo.
Algunos puntos clave:
Sobrecarga de creación: lanzar un hilo tiene un costo no trivial; conviene hacerlo solo cuando el trabajo lo justifique.
Equilibrio de carga: si los hilos procesan bloques de distinto tamaño o complejidad, algunos núcleos quedarán inactivos antes que otros.
Afinidad de CPU: en casos de alta demanda puede ser beneficioso fijar ciertos hilos a núcleos específicos, aunque esto se gestiona a nivel del sistema operativo.
Reutilización de hilos: en tareas repetitivas conviene emplear un thread pool, que mantiene un conjunto fijo de hilos reutilizables en lugar de crearlos y destruirlos continuamente.
Ejemplo práctico: elegir dinámicamente según carga
Una mejora práctica consiste en ajustar el número de hilos según la carga real y no solo por hardware. Por ejemplo:
unsigned int hardware = std::thread::hardware_concurrency();
unsigned int num_threads = std::min(hardware != 0 ? hardware : 2,
total_tasks / min_work_per_thread);
num_threads = std::max(1u, num_threads); // garantizar al menos un hilo
Esta estrategia evita crear más hilos de los necesarios cuando la tarea es pequeña, pero aprovecha al máximo los núcleos disponibles cuando hay suficiente trabajo.
Identificar hilos
Cuando se trabaja con múltiples hilos, puede ser necesario distinguir cuál de ellos está ejecutando una determinada parte del código. Por ejemplo, podríamos querer registrar qué hilo está procesando una tarea, asignar recursos según el hilo, o simplemente generar trazas para depurar comportamientos concurrentes.
La biblioteca estándar de C++ proporciona un mecanismo seguro y eficiente para realizar esta identificación mediante la clase std::thread::id.
Obtener el identificador de un hilo
Existen dos formas principales de obtener un identificador de tipo std::thread::id:
Desde un objeto
std::threadstd::thread t(f); std::thread::id id = t.get_id();
El método get_id() devuelve el identificador del hilo asociado al objeto.
Si el objeto std::thread no está asociado a ningún hilo de ejecución (por ejemplo, porque fue creado sin función o ya se le hizo join() o detach()), la llamada devuelve un identificador por defecto, que representa “ningún hilo”.
Desde el hilo actual
std::thread::id id = std::this_thread::get_id();Esta función devuelve el identificador del hilo que la invoca. Es especialmente útil dentro de funciones que pueden ser ejecutadas por distintos hilos, ya que permite saber cuál de ellos está ejecutando la llamada.
Propiedades del identificador
Los objetos de tipo std::thread::id son copiables y comparables. Esto permite usarlos de forma natural para verificar si dos hilos son el mismo:
if (t1.get_id() == t2.get_id())
std::cout << "Ambos objetos representan el mismo hilo\n";
Si dos identificadores son iguales, representan el mismo hilo o ambos son “ningún hilo”. Además, la clase proporciona un orden total: pueden compararse con <, >, etc., lo que permite usarlos como claves en contenedores asociativos, tanto ordenados (std::map) como no ordenados (std::unordered_map), gracias a que existe una especialización de std::hash<std::thread::id>.
Ejemplo:
std::unordered_map<std::thread::id, std::string> thread_names;
thread_names[std::this_thread::get_id()] = "Hilo principal";
Uso en registro y depuración
El identificador de hilo resulta especialmente útil para generar trazas de ejecución.
Por ejemplo, en un sistema concurrente de procesamiento de tareas, podríamos imprimir qué hilo está procesando cada bloque de datos:
void process_task(int task_id) {
std::cout << "Hilo " << std::this_thread::get_id()
<< " procesando tarea " << task_id << '\n';
}
Cada ejecución imprimirá un valor distinto de std::thread::id, lo que permite identificar fácilmente qué hilo realizó cada operación. El formato exacto del identificador depende de la implementación, pero la norma garantiza que hilos diferentes producirán salidas distintas**, y que **hilos iguales producirán la misma salida.
Ejemplo: distinguir el hilo maestro de los trabajadores
Supongamos que el hilo principal lanza varios hilos para realizar trabajo paralelo, pero necesita ejecutar una tarea especial que solo él debe realizar. En ese caso, puede almacenar su propio identificador antes de lanzar los hilos y luego compararlo dentro del código compartido:
std::thread::id master_thread;
void some_core_part_of_algorithm() {
if (std::this_thread::get_id() == master_thread) {
do_master_thread_work(); // tarea exclusiva del hilo maestro
}
do_common_work(); // tarea común a todos los hilos
}
int main() {
master_thread = std::this_thread::get_id();
std::thread worker1(some_core_part_of_algorithm);
std::thread worker2(some_core_part_of_algorithm);
some_core_part_of_algorithm(); // ejecuta el maestro
worker1.join();
worker2.join();
}
Aquí, todos los hilos ejecutan la misma función, pero solo el hilo maestro realiza la sección especial al comparar su std::thread::id con el almacenado.
Asociar datos a hilos mediante identificadores
En ocasiones, es útil mantener información específica de cada hilo, como estadísticas o configuraciones locales.
Si no se desea usar thread-local storage, puede construirse un contenedor donde la clave sea el identificador del hilo:
std::map<std::thread::id, ThreadStats> stats_map;
void log_event(std::string event) {
stats_map[std::this_thread::get_id()].events.push_back(event);
}
Este enfoque resulta práctico cuando se requiere que un hilo externo (por ejemplo, un controlador) acceda a información de otros hilos mediante sus identificadores.
Consideraciones sobre la reutilización de IDs
Aunque los identificadores son únicos durante la vida activa de un hilo, los sistemas operativos pueden reutilizarlos una vez que un hilo termina y su recurso ha sido liberado. Esto significa que un std::thread::id antiguo no debe conservarse para identificar un hilo después de que este haya finalizado, ya que podría ser asignado a otro hilo nuevo.
Conclusion
La gestión de argumentos, el control sobre la propiedad de los datos y la organización de varios hilos sirven como base práctica para construir programas concurrentes más claros y manejables. Estos mecanismos ayudan a distribuir el trabajo de forma ordenada, a mantener un seguimiento preciso de cada hilo y a evitar errores típicos relacionados con el tiempo de vida de los datos. A partir de aquí, el siguiente paso será es explorar cómo varios hilos pueden acceder y manipular la misma información sin generar inconsistencias. Ese será el punto de partida de la próxima sección: compartir datos entre hilos.





