Ejercicios para practicar para el 1er parcial
Link al código fuente de esta sección
En los siguientes ejercicios, se explorarán diversos aspectos de la programación concurrente utilizando Rust.
- La consigna detallará (qué implementar).
- Los resultados o comportamientos esperados se describirán a alto nivel (tests genéricos, sin código específico, o ejemplos de uso).
1. Fundamentos de Concurrencia en Rust
Instrucciones comunes:
- Implementar las soluciones utilizando las primitivas de concurrencia de la librería estándar de Rust (
std::thread,std::sync, etc.). - Prestar atención a la seguridad de hilos (
Send,Sync) y al manejo de datos compartidos. - Considerar el uso de
Mutex,RwLock,Arc, yAtomicssegún sea apropiado.
Ejercicio 1.1: Contador Concurrente y Condiciones de Carrera
Consigna
Implementar un struct Counter que pueda ser compartido y modificado de forma segura por múltiples hilos.
- El
Counterdebe tener un métodonew(initial_value: i32) -> Self. - Debe proveer un método
increment(&self)que aumente su valor interno en 1. - Debe proveer un método
get_value(&self) -> i32que retorne el valor actual.
Se deben considerar dos enfoques o discusiones:
- Analizar por qué una implementación ingenua que intente modificar directamente un
i32compartido entre hilos sin sincronización no es segura o no compilaría fácilmente en Rust sinunsafe. (Referencia:race_conditions.rsmuestra cómo Rust previene esto). - Implementar una versión segura del
Counterutilizandostd::sync::Mutexpara proteger el acceso al valor. - (Opcional) Implementar una versión alternativa utilizando
std::sync::atomic::AtomicI32.
Resultados esperados
- Al lanzar N hilos, donde cada hilo llama al método
increment()M veces sobre una instancia compartida delCounter(protegido conMutexoAtomicI32). - Después de que todos los hilos terminen, el valor final retornado por
get_value()debe serinitial_value + (N * M). - La implementación debe ser segura frente a condiciones de carrera, garantizando que cada incremento se aplique correctamente.
Ejercicio 1.2: Cuenta Bancaria Concurrente
Consigna
Implementar una estructura que simule las operaciones de una cuenta bancaria, permitiendo depósitos, extracciones y consultas de saldo de forma concurrente y segura.
Definir un trait BankAccount:
#![allow(unused)] fn main() { pub trait BankAccount { fn new(initial_balance: f64) -> Self; fn deposit(&self, amount: f64); fn withdraw(&self, amount: f64) -> Result<(), String>; // Retorna Ok o Err con mensaje fn get_balance(&self) -> f64; } }
Proveer dos implementaciones de este trait:
MutexBankAccount: Utilizarstd::sync::Mutex<f64>para gestionar el saldo.RwLockBankAccount: Utilizarstd::sync::RwLock<f64>para gestionar el saldo, permitiendo múltiples lecturas concurrentes.
Resultados esperados
- Múltiples hilos deben poder interactuar concurrentemente con una instancia de
MutexBankAccountoRwLockBankAccount. - Las operaciones de
depositywithdrawdeben modificar el saldo de manera atómica. withdrawdebe retornar unErrsi los fondos son insuficientes, sin modificar el saldo. Si tiene éxito, retornaOk(()).get_balancedebe retornar el saldo actual.- Ejemplo de escenario:
- Cuenta inicia con saldo 0.
- Hilo A deposita 100.
- Hilo B deposita 50.
- Hilo C intenta extraer 200 (debe fallar).
- Hilo D extrae 30.
- Saldo final esperado: 120.
- Todas las operaciones deben reflejarse correctamente sin importar la intercalación de los hilos.
Ejercicio 1.3: Suma Paralela de Elementos de un Vector
Consigna
Implementar una función sum_parallel(nums: &[i32], m: usize) -> i32 que calcule la suma de los elementos de un slice de enteros (&[i32]) de forma paralela.
- El slice
numsdebe ser dividido enmsub-slices (chunks) de tamaño lo más similar posible. - La suma de cada sub-slice debe ser calculada por un hilo (
std::thread) separado. - La función debe esperar a que todos los hilos terminen, recolectar sus sumas parciales y retornar la suma total.
- Considerar el manejo de casos borde, como un vector vacío o
m=0(debe entrar en pánico sim=0omes mayor que el número de elementos de una forma que no tenga sentido, aunque dividir en más chunks que elementos es posible).
Resultados esperados
sum_parallel(&[1, 2, 3, 4, 5], 2)podría dividir el trabajo en[1, 2, 3]y[4, 5], sumarlos en paralelo y retornar15.sum_parallel(&[], m)debe retornar0para cualquierm > 0.- El resultado de
sum_paralleldebe ser idéntico al de una suma secuencial (e.g.,nums.iter().sum()). - La función debe entrar en pánico si
mes 0 (como se muestra enparallel_vector_sum.rs). - Evaluar el rendimiento (conceptualmente) en comparación con una suma secuencial para vectores grandes.
Ejercicio 1.4: Problema del Productor-Consumidor con Buffer Acotado
Consigna
Implementar una estructura BoundedBuffer<T> que sirva como un canal de comunicación de capacidad limitada entre hilos productores y consumidores.
- La
BoundedBuffer<T>debe encapsular los datos compartidos (e.g., unVecDeque<T>para el buffer, su capacidad máxima y tamaño actual) protegidos por unstd::sync::Mutex. - Debe utilizar dos
std::sync::Condvar:not_empty: Para que los hilos consumidores esperen si el buffer está vacío.not_full: Para que los hilos productores esperen si el buffer está lleno.
- Se deben implementar (o se pueden proveer como en
bounded_buffer.rs) structsProduceryConsumerque interactúen con elBoundedBuffer:Producer::produce(&self, item: T): Añade unitemal buffer. Si el buffer está lleno, el productor debe bloquearse (esperar ennot_full). Al producir, notifica a través denot_empty.Consumer::consume(&self) -> T: Extrae unitemdel buffer. Si el buffer está vacío, el consumidor debe bloquearse (esperar ennot_empty). Al consumir, notifica a través denot_full.
Resultados esperados
- Múltiples hilos productores y consumidores pueden operar concurrentemente sobre la misma instancia de
BoundedBuffersin corrupción de datos ni deadlocks. - Los productores se bloquean eficazmente cuando el buffer alcanza su capacidad máxima y se reanudan cuando se libera espacio.
- Los consumidores se bloquean eficazmente cuando el buffer está vacío y se reanudan cuando se añaden nuevos ítems.
- Los ítems producidos son consumidos correctamente (sin pérdidas ni duplicados).
- El uso de
std::thread::sleepdentro deproduce/consume(como en el ejemplo) puede ayudar a visualizar la alternancia y el bloqueo/desbloqueo de los hilos.
Ejercicio 1.5: Implementación de un Buffer Circular Concurrente
Consigna
Desarrollar una estructura ConcurrentCircularBuffer<T> que implemente un buffer circular con semántica de productor-consumidor, seguro para acceso concurrente.
- La estructura interna debe manejar un buffer de tamaño fijo (e.g.,
Vec<Option<T>>) con punterosheadytailpara la lógica circular, y contadores desizeycapacity. - Los datos compartidos (buffer, punteros, contadores) deben estar protegidos por un
std::sync::Mutex. - Se deben emplear dos
std::sync::Condvar(not_emptyynot_full) para la sincronización:- Método
add(&self, item: T): Si el buffer está lleno, el hilo productor debe esperar en laCondvarnot_full. Tras añadir el ítem, debe notificar a un posible consumidor mediantenot_empty.notify_one()(onotify_all()). - Método
remove(&self) -> T: Si el buffer está vacío, el hilo consumidor debe esperar en laCondvarnot_empty. Tras extraer el ítem, debe notificar a un posible productor mediantenot_full.notify_one()(onotify_all()).
- Método
- (Opcional) Considerar la implementación base de un
CircularBuffer<T>no concurrente primero. - Revisar la lógica de notificación: asegurarse de que se notifica a la
Condvarcorrecta (e.g., encircular_buffer.rselremoveoriginal podría tenernot_empty.notify_one()donde debería sernot_full.notify_one()).
Resultados esperados
- El
ConcurrentCircularBufferpermite la interacción segura entre múltiples productores y consumidores. - Se previene el desbordamiento (overflow) y el subdesbordamiento (underflow) del buffer mediante el bloqueo y desbloqueo correcto de los hilos.
- Las
Condvarse utilizan eficazmente para minimizar la espera activa (busy-waiting). - El comportamiento es robusto frente a condiciones de carrera.
Ejercicio 1.6: Cola Concurrente - De Busy-Waiting a Condvar
Consigna
Explorar y mejorar la implementación de una cola (queue) concurrente simple, pasando de un enfoque de espera activa (busy-waiting) a uno más eficiente usando variables de condición (Condvar).
-
Análisis del Busy-Waiting:
- Examinar el código de
queue_behaviour()(presente enqueue.rs), que utiliza unMutex<VecDeque<T>>. - El hilo consumidor intenta extraer elementos en un bucle, adquiriendo el lock repetidamente incluso si la cola está vacía (busy-waiting).
- Identificar las desventajas de este enfoque (principalmente, consumo innecesario de CPU).
- Examinar el código de
-
Implementación con
Condvar:- Modificar o re-implementar la lógica en una función similar a
queue_behaviour_with_condvar(). - Además del
Mutexpara laVecDeque, introducir unastd::sync::Condvar(e.g.,not_empty). - El hilo consumidor, si encuentra la cola vacía, debe esperar en la
Condvar(condvar.wait(lock_guard)). Es crucial que esta espera se realice dentro de un bucle que vuelva a comprobar la condición de la cola tras despertar, para manejar despertares espurios (spurious wakeups). - El hilo productor, después de añadir un elemento a la cola, debe notificar a un consumidor en espera (
condvar.notify_one()).
- Modificar o re-implementar la lógica en una función similar a
Resultados esperados
- Al ejecutar la versión con busy-waiting, se observa un alto uso de CPU por parte del hilo consumidor, especialmente cuando la cola está vacía frecuentemente.
- Al ejecutar la versión con
Condvar, el hilo consumidor debe mostrar un uso de CPU significativamente menor cuando está esperando, ya que el hilo se bloquea en lugar de sondear activamente. - Ambas versiones deben transferir correctamente los ítems del productor al consumidor, pero la versión con
Condvardebe hacerlo de manera mucho más eficiente en términos de recursos del sistema.
Ejercicio 1.7: Comunicación Multi-Productor a Consumidor Único con Canales MPSC
Consigna
Implementar un patrón de concurrencia donde múltiples hilos productores envían datos a un único hilo consumidor utilizando los canales multi-productor, single-consumer (mpsc) de la librería estándar de Rust (std::sync::mpsc).
- Crear un canal
mpsc::channel(). - Lanzar N hilos productores. Cada productor debe:
- Obtener una copia clonada del extremo
Sender<T>del canal. - Enviar uno o más mensajes (e.g., un ID de hilo, un dato calculado, etc.) a través de su
Sender. - Asegurarse de que el
Senderclonado se libere (drop) cuando el productor haya terminado de enviar mensajes para permitir que el canal se cierre eventualmente.
- Obtener una copia clonada del extremo
- El hilo consumidor debe:
- Utilizar el extremo
Receiver<T>del canal. - Recibir mensajes en un bucle. El bucle debe terminar cuando el canal se cierre (es decir, cuando todos los
Senders hayan sido liberados). - Procesar o imprimir cada mensaje recibido.
- Utilizar el extremo
Resultados esperados
- El hilo consumidor recibe todos los mensajes enviados por todos los hilos productores.
- El programa termina correctamente después de que todos los mensajes han sido procesados y el canal se ha cerrado (el
Receiverya no puede obtener más mensajes). - Se puede observar cómo los mensajes de diferentes productores pueden intercalarse al ser recibidos por el consumidor, dependiendo del scheduling de los hilos.
- Este ejercicio demuestra un caso de uso fundamental de los canales
mpscpara la agregación de datos o tareas desde múltiples fuentes.
Ejercicio 1.8: Pipeline de Procesamiento de N Etapas con Canales
Consigna
Construir un pipeline de procesamiento de datos donde un dato inicial atraviesa una secuencia de N etapas de transformación, cada una ejecutándose en un hilo separado y comunicándose con la siguiente mediante canales mpsc.
- Definir una serie de funciones de transformación (e.g.,
fn(T) -> T). - Implementar una estructura o lógica (como la
PipelineyPipelineNodeenchannels.rs) que:- Tome una lista de estas funciones de transformación.
- Para cada función, cree un hilo (una etapa del pipeline).
- Cree un canal
mpscentre cada par de etapas consecutivas (la salida de la etapaies la entrada de la etapai+1). - El primer hilo del pipeline recibe un valor inicial (posiblemente a través de un
Senderinicial). - Cada hilo intermedio recibe un valor de su predecesor, aplica su función de transformación, y envía el resultado a su sucesor.
- El último hilo del pipeline envía su resultado a un
Receiverfinal desde donde se puede obtener el resultado global.
- La función
rundel pipeline debe tomar un valor inicial, enviarlo a la primera etapa, y retornar el valor recibido de la última etapa.
Resultados esperados
- Un valor de entrada es procesado secuencialmente por todas las etapas del pipeline, con cada transformación ocurriendo en un hilo dedicado.
- El resultado final obtenido es el producto de aplicar todas las funciones de transformación en el orden especificado.
- Ejemplo: Si el valor inicial es
Xy las etapas sonf1, f2, f3, el resultado final debe serf3(f2(f1(X))). - El sistema debe manejar la creación, el enlace y el cierre de los canales correctamente. (Considerar cómo se manejan los errores o pánicos en una etapa; el ejemplo en
channels.rsusaexpectyunwrap).
Ejercicio 1.9: El Problema de los Filósofos Cenadores
Consigna
Implementar una simulación del clásico problema de concurrencia de los "Filósofos Cenadores", con el objetivo de evitar deadlocks y permitir que los filósofos coman.
- Habrá N filósofos sentados en una mesa redonda. Entre cada par de filósofos adyacentes hay un tenedor (N tenedores en total).
- Cada filósofo alterna entre dos estados: pensar y comer.
- Para comer, un filósofo necesita adquirir los dos tenedores que tiene a su lado (el izquierdo y el derecho).
- La implementación debe incluir:
- Un struct
Philosopherque represente a un filósofo, con su ID (o posición) y la lógica para pensar y comer. - Un struct
Table(o similar) para representar el estado compartido de los tenedores. Este estado (e.g., unVec<bool>indicando si cada tenedor está disponible) debe ser protegido por unstd::sync::Mutex. - Una
std::sync::Condvarpara que los filósofos puedan esperar de manera eficiente si los tenedores que necesitan no están disponibles.
- Un struct
- Lógica para comer (método
eatdelPhilosopher):- Adquirir el lock del
Mutexde la mesa. - Mientras ambos tenedores necesarios (izquierdo y derecho) no estén disponibles: esperar en la
Condvar(condvar.wait(lock_guard)). La guarda del mutex se libera mientras se espera y se vuelve a adquirir al despertar. - Cuando ambos tenedores estén disponibles (tras despertar y re-evaluar la condición), marcarlos como "en uso".
- Liberar el lock del
Mutexde la mesa (importante: esto permite a otros filósofos intentar tomar tenedores mientras este come). - Simular el tiempo de comida (e.g.,
std::thread::sleep). - Volver a adquirir el lock del
Mutexde la mesa. - Marcar ambos tenedores como "disponibles".
- Notificar a todos los demás filósofos que podrían estar esperando (
condvar.notify_all()). - Liberar el lock del
Mutex.
- Adquirir el lock del
Resultados esperados
- La simulación se ejecuta con N filósofos (e.g., 5) y N tenedores.
- Los filósofos pueden alternar entre pensar y comer sin que el sistema entre en deadlock (donde ningún filósofo puede progresar).
- Se demuestra que los filósofos esperan si no pueden obtener ambos tenedores y son notificados cuando los tenedores se liberan.
- (Opcional) Considerar y discutir brevemente otras estrategias para la prevención de deadlocks en este problema (e.g., ordenamiento global de adquisición de tenedores, limitar el número de comensales).
Ejercicio 1.10: Implementación de Merge Sort Paralelo
Consigna
Desarrollar una versión paralela del algoritmo de ordenamiento Merge Sort para un slice de enteros (&[i32]).
- La implementación debe incluir las siguientes partes:
- Una función
merge(first_slice: &[i32], second_slice: &[i32]) -> Vec<i32>: Esta función toma dos slices ya ordenados y los combina en un nuevoVec<i32>que también está ordenado. - (Opcional pero útil como referencia) Una función
sequential_merge_sort(slice: &[i32]) -> Vec<i32>: La implementación recursiva estándar de Merge Sort. - Una función
parallel_merge_sort(slice: &[i32]) -> Vec<i32>: Esta es la versión paralela.- Debe manejar el caso base: si el slice es suficientemente pequeño (e.g., longitud 0 o 1), se retorna directamente o se ordena secuencialmente.
- Para el paso recursivo: dividir el slice en dos mitades.
- Ordenar al menos una de las mitades en un nuevo hilo. Por ejemplo, la primera mitad puede ordenarse en el hilo actual (recursivamente, podría ser secuencial o paralelo dependiendo de la profundidad y un umbral), y la segunda mitad se ordena en un hilo spawnneado (
std::thread::scopees útil aquí). - Esperar a que el hilo (o hilos) terminen y obtener las dos mitades ordenadas.
- Combinar las dos mitades ordenadas usando la función
merge.
- Una función
Resultados esperados
parallel_merge_sortdebe producir un vector correctamente ordenado, idéntico al resultado de un Merge Sort secuencial.- Debe funcionar para diversos casos de prueba: arrays vacíos, de un elemento, ya ordenados, en orden inverso, con elementos duplicados, etc.
- Para arrays de tamaño considerable, la versión
parallel_merge_sortdebería, idealmente, ejecutarse más rápido que una versión puramente secuencial. Esto se puede verificar mediante benchmarking (comparando tiempos de ejecución). - (Para discusión) Considerar el uso de un umbral: si el tamaño del sub-array a ordenar es menor que cierto umbral, cambiar a una versión secuencial para evitar el overhead de crear hilos para tareas muy pequeñas. También, discutir cómo se podría aumentar el grado de paralelismo (e.g., lanzando ambas mitades a hilos separados si el umbral lo permite).
Ejercicio 1.11: Suma de Matrices Secuencial y Paralela
Consigna
Implementar una estructura Matrix para representar matrices de números de punto flotante (f64) y desarrollar métodos para su suma, tanto de forma secuencial como paralela.
- Definir un struct
Matrixque encapsule los datos de la matriz (e.g.,Vec<Vec<f64>>). - Implementar las siguientes funcionalidades para la suma de dos matrices (
selfyother: &Matrix):- Suma Secuencial (
add_sequential):- Debe sumar las dos matrices elemento por elemento y retornar una nueva
Matrixcon el resultado. - La operación asume que ambas matrices tienen las mismas dimensiones. (Considerar añadir una verificación explícita de dimensiones y manejar el error si no coinciden, e.g., retornando un
Result<Matrix, String>o causando unpaniccontrolado).
- Debe sumar las dos matrices elemento por elemento y retornar una nueva
- Suma Paralela (
add_parallel):- Debe realizar la suma de las dos matrices utilizando hilos para paralelizar el cálculo.
- Una estrategia común es asignar a cada hilo el cálculo de una fila completa (o un subconjunto de filas) de la matriz resultante.
std::thread::scopepuede ser útil para gestionar los hilos. - Al igual que la suma secuencial, esta operación asume dimensiones compatibles.
- Suma Secuencial (
- (Opcional) Crear una enumeración
OperationMethod { SEQUENTIAL, PARALLEL }y un método principaladd_matrix(&self, other: &Matrix, method: OperationMethod) -> Matrixque delegue a la implementación correspondiente.
Resultados esperados
- Para dos matrices de entrada dadas, tanto
add_sequentialcomoadd_paralleldeben producir la mismaMatrixresultante. - El código debe manejar correctamente matrices de diversas dimensiones (e.g., cuadradas, rectangulares, 1x1) y con diferentes tipos de valores (positivos, negativos, cero).
- Para matrices de tamaño suficientemente grande, la versión
add_paralleldebería mostrar una mejora en el tiempo de ejecución en comparación conadd_sequential. Esto se puede verificar mediante benchmarking. - Si se implementa la verificación de dimensiones, las operaciones deben fallar de forma predecible (e.g., retornar
Erropanic) cuando se intentan sumar matrices de dimensiones incompatibles. - (Para discusión) Explorar y comparar diferentes granularidades para la paralelización: ¿por fila, por columna, por bloques de elementos? ¿Cuál podría ser más eficiente y por qué?