CS INTERACTIVE WORKSHOP

Un taller de computación para aprender haciendo

Hecho para estudiantes de computación · ECCI · UCR
Creado por Josué Torres Sibaja

Hola, soy Bit. Te muestro el taller. ¿Qué es esto?

¿Qué es esto?

Todo lo que necesitas saber antes de entrar, en un minuto.

1

¿Qué es CS Interactive Workshop?

Un taller en el navegador donde los temas difíciles de computación se ven, se oyen y se tocan. En vez de leer sobre un deadlock, lo provocas. En vez de memorizar Big-O, ves la diferencia.

2

¿Para quién es?

Para estudiantes de Ciencias de la Computación, en especial de la ECCI de la UCR, que están llevando algoritmos, programación concurrente, redes o bases de datos, o que se preparan para entrevistas técnicas. También para cualquier curioso.

3

¿Qué puedo hacer aquí?

Escoges una mesa de trabajo (un curso o tema) y luego una estación. Cada estación tiene tres pasos:

  • Experimenta: una simulación, o un depurador que recorre el código línea por línea.
  • Entiende: la idea detrás, explicada simple.
  • Implementa: código y consejos para tu proyecto.
4

¿Qué voy a aprender?

  • Algoritmos y estructuras: 8 ordenamientos compitiendo; pilas, listas y árboles.
  • Concurrencia: hilos, mutex y semáforos en una rotonda.
  • Redes: sockets TCP, HTTP y un servidor con pool de hilos.
  • Problemas de código: palíndromos, islas, pilas, programación dinámica y ventanas deslizantes, de fáciles a difíciles.
  • Bases de datos: SQL ejecutado cláusula por cláusula.
5

¿Tengo que instalar algo?

No. Todo corre en tu navegador. Si quieres ir más lejos, el repositorio incluye los motores originales en C y C++ para compilarlos en tu máquina.

6

¿Quién lo hizo?

Lo creó Josué Torres Sibaja, estudiante de la ECCI, a partir de proyectos reales de sus cursos. Es código abierto (MIT): puedes usarlo, mejorarlo y proponer estaciones nuevas.

Ver el código en GitHub

Mi espacio

Tu progreso en el taller.

Invitado
Tu progreso se guarda en este dispositivo.

Retos completados
0 / 0
Estaciones terminadas
0 / 0
Empezar
Taller /

Ordenamiento

Arreglo inicial
50
80%
Sin ordenar Comparando Intercambiando En su lugar
Estado
Listo
Comparaciones
0
Escrituras
0
Tiempo
0.00s

Quick Sort

Ojo

¿Cuánto cuesta?

Así crece el trabajo cuando el arreglo crece.

Mejor caso
O(n log n)
Caso promedio
O(n log n)
Peor caso
O(n²)
Memoria extra
O(log n)
¿Estable?
No

Experimentos para probar

  1. Insertion Sort + Casi ordenado. Termina rapidísimo: su mejor caso es O(n). Ahora prueba Invertido.
  2. Quick Sort + Pocos valores. Fíjate cómo muchos elementos iguales le cuestan más comparaciones de lo esperado.
  3. Selection vs Bubble con 150 elementos. Compara cuántas escrituras hace cada uno. Selection casi no mueve nada.
  4. Radix Sort. No compara nunca. Mira el contador de comparaciones quedarse en cero.

Quick Sort en C++


              

Consejos

  • Prueba siempre con arreglos vacíos, de un elemento, repetidos e invertidos.
  • En Quick Sort, usa un pivote aleatorio o la mediana de tres para evitar el O(n²).
  • Para n pequeño (≈16), cambiar a Insertion Sort acelera los algoritmos recursivos.
  • Cuenta comparaciones e intercambios en tus pruebas: es la forma más clara de verificar la complejidad.

En la vida real

  • std::sort usa Introsort: Quick + Heap + Insertion.
  • std::stable_sort se basa en Merge Sort.
  • Python y Java (objetos) usan Timsort, un híbrido de Merge e Insertion.
  • Para enteros de tamaño fijo, Radix Sort puede ganarle a todos.
Arreglo inicial
40
60%
Quick Sort¡Primero!
Comparaciones
0
Escrituras
0
Operaciones
0
Bubble Sort¡Primero!
Comparaciones
0
Escrituras
0
Operaciones
0
Sin ordenar Comparando Escribiendo Terminó Estado: Listo

Una carrera justa

Los dos algoritmos ordenan exactamente el mismo arreglo. En cada tic, cada uno hace una sola operación: comparar dos valores o escribir uno. Gana el que necesita menos operaciones para terminar.

Por eso no medimos segundosLos segundos dependen de tu computadora. Las operaciones no: son justo lo que cuenta la notación O.

¿Cuánto cuesta cada uno?

Así crece el trabajo cuando el arreglo crece.

AlgoritmoMejorPromedioPeor
Quick Sortn log nn log nn²
Merge Sortn log nn log nn log n
Heap Sortn log nn log nn log n
Shell Sortn log nn^(4/3)n²
Insertion Sortnn²n²
Selection Sortn²n²n²
Bubble Sortnn²n²
Radix Sortn·kn·kn·k

Carreras para probar

  1. Quick Sort vs Bubble Sort, Aleatorio. n log n contra n². Sube a 120 elementos: la distancia entre los dos crece mucho más rápido que el arreglo.
  2. Insertion Sort vs Quick Sort, Casi ordenado. Gana el «lento». Insertion casi no tiene que mover nada y Quick Sort, con el pivote al final, parte muy mal un arreglo casi ordenado.
  3. Merge Sort vs Heap Sort. Los dos son n log n y aun así uno le saca ventaja al otro: la O esconde las constantes.
  4. Selection Sort vs Insertion Sort, Invertido. Mira las escrituras: Selection compara muchísimo pero casi no escribe.

Tu propia carrera en C++

#include <algorithm>
#include <chrono>
#include <iostream>
#include <random>
#include <vector>

long long comparaciones = 0;
bool menor(int a, int b) { ++comparaciones; return a < b; }  // cuenta cada comparación

int main() {
  std::mt19937 rng(42);                     // misma semilla: el mismo arreglo para todos
  std::vector<int> base(100000);
  for (int& x : base) x = rng() % 1000;

  auto correr = [&](const char* nombre, auto ordenar) {
    std::vector<int> a = base;               // cada retador ordena su propia copia
    comparaciones = 0;
    auto t0 = std::chrono::steady_clock::now();
    ordenar(a);
    auto t1 = std::chrono::steady_clock::now();
    std::cout << nombre << ": " << comparaciones << " comparaciones, "
              << std::chrono::duration<double, std::milli>(t1 - t0).count() << " ms\n";
  };

  correr("std::sort",        [](auto& a) { std::sort(a.begin(), a.end(), menor); });
  correr("std::stable_sort", [](auto& a) { std::stable_sort(a.begin(), a.end(), menor); });
}

Para que la carrera sea justa

  • Dale a cada algoritmo una copia del mismo arreglo, nunca uno ya ordenado por el anterior.
  • Usa una semilla fija para poder repetir el experimento.
  • Compila con -O2 y corre varias veces: la primera vuelta suele ser más lenta.
  • Cuenta operaciones además de medir tiempo: el tiempo cambia de máquina a máquina, las operaciones no.

Lo que vas a notar

  • Con n grande, un n² pierde siempre contra un n log n.
  • Con n pequeño o casi ordenado, Insertion Sort puede ganarle a cualquiera.
  • Dos algoritmos con la misma O pueden tardar distinto por la caché y las constantes.
Sin riesgo de deadlock
60%

El experimento

Apaga los semáforos y mira qué pasa cuando cuatro autos se esperan en círculo.

Deadlock: cada auto espera al de adelante y nadie avanza. Pulsa «Resolver».
Estado
Fluye
Autos (hilos)
0
En la rotonda
0
Salieron
0

¿Por qué se traba la rotonda?

Cada auto es un hilo y cada cuadrante es un recurso. Un deadlock aparece solo si se cumplen las cuatro condiciones de Coffman al mismo tiempo:

  1. Exclusión mutua: en un cuadrante cabe un número limitado de autos.
  2. Retener y esperar: un auto ocupa su cuadrante mientras espera el siguiente.
  3. Sin expropiación: nadie puede sacar un auto de la rotonda a la fuerza.
  4. Espera circular: A espera a B, B a C, C a D… y D espera a A.
La soluciónUn semáforo de entrada que nunca deja que la rotonda se llene por completo rompe la espera circular: siempre queda un hueco para avanzar.
pthread_mutex_t

Mutex

La llave de un baño: una persona a la vez, y quien la toma la devuelve.

Protege secciones críticas cortas. Tiene dueño: solo el hilo que hizo lock puede hacer unlock.

sem_t

Semáforo

Un parqueo con N espacios: si hay campo entras, si no esperas.

Un contador sin dueño. Sirve para limitar capacidad y para que un hilo le avise a otro.

pthread_barrier_t

Barrera

El guía del tour: nadie sigue hasta que llegue todo el grupo.

Sincroniza fases. La simulación de calor la usa para que ningún hilo empiece la iteración siguiente antes de tiempo.

_Atomic

Atómicos

Un torniquete: avanza de un clic, sin candados.

Operaciones que el procesador hace de una sola vez. Ideales para contadores y banderas compartidas.

La rotonda en C, simplificada

// Compila con: gcc -std=c11 -pthread rotonda.c
#include <pthread.h>
#include <semaphore.h>

#define QUADRANTS 4
#define CAPACIDAD 2                  // autos por cuadrante
int recorrido = 2;                    // cuadrantes que avanza cada auto
sem_t entrada;                       // cupo total de la rotonda
sem_t cuadrante[QUADRANTS];          // capacidad de cada cuadrante

void* auto_hilo(void* arg) {
  int q = *(int*)arg;                   // cuadrante de entrada
  sem_wait(&entrada);                  // nunca se llena del todo
  sem_wait(&cuadrante[q]);
  for (int i = 0; i < recorrido; ++i) {
    int sig = (q + 1) % QUADRANTS;
    sem_wait(&cuadrante[sig]);         // pide el siguiente…
    sem_post(&cuadrante[q]);           // …y suelta el actual
    q = sig;
  }
  sem_post(&cuadrante[q]);
  sem_post(&entrada);
  return NULL;
}

int main(void) {
  sem_init(&entrada, 0, QUADRANTS * CAPACIDAD - 1);  // la clave: -1
  for (int i = 0; i < QUADRANTS; ++i) sem_init(&cuadrante[i], 0, CAPACIDAD);
  /* crear hilos con pthread_create, esperarlos con pthread_join */
}

Consejos

  • Toma los recursos siempre en el mismo orden. Es la forma más sencilla de evitar deadlocks.
  • Mantén las secciones críticas cortas: nada de printf ni E/S con un mutex tomado.
  • Cada sem_init y pthread_mutex_init lleva su destroy.
  • Nunca uses espera activa (while(!listo);): bloquea con un semáforo o una variable de condición.

Para depurar

  • Compila con -fsanitize=thread para detectar condiciones de carrera.
  • valgrind --tool=helgrind ./prog encuentra errores de sincronización.
  • Si sospechas de un deadlock, adjunta gdb y mira dónde está cada hilo con thread apply all bt.
La placa
Hilos
Reparto de filas
lento

1 a 3: mira cada fila. 4 en adelante: muchas iteraciones por cuadro.

0.24

Estable: r ≤ 1/4.

Pincel

Haz clic o arrastra sobre la placa para dibujar una fuente fija.

La simulación explotó: con r mayor que 1/4 el método es inestable.
Iteración
0
Cambio máximo
—
Aceleración
1.00x
Eficiencia
100%
En la barrera
0%
Así trabajan los hilos

Los hilos

calculandoesperando en la barrera

Las últimas iteraciones

Cada columna es una iteración. Nadie cruza la línea punteada (la barrera) hasta que termina el último.

¿Cuándo parar?

El cambio más grande de cada iteración, en escala logarítmica. Cuando baja de ε, la placa llegó al equilibrio.

La idea: cada punto se parece a sus vecinos

El calor fluye de lo caliente a lo frío. Si dividimos la placa en una cuadrícula, cada punto se acerca un poquito al promedio de sus cuatro vecinos en cada paso. Repetir eso muchas veces es resolver la ecuación del calor.

nuevo[i][j] = viejo[i][j] + r * ( viejo[i-1][j] + viejo[i+1][j]
                                + viejo[i][j-1] + viejo[i][j+1]
                                - 4 * viejo[i][j] )

Dos placas, no una

Copiar la tarea del compañero mientras él la sigue corrigiendo sale mal.

Se lee siempre de la placa vieja y se escribe en la nueva (doble búfer). Así ningún hilo lee un valor a medio actualizar, y el resultado es idéntico con 1 o con 8 hilos, sin importar en qué orden terminen.

Repartir la placa

Cada hilo recibe una franja de filas contiguas. Para calcular el borde de su franja necesita leer una fila del vecino (las "filas fantasma"). No hay problema: esa fila está en la placa vieja, que nadie modifica durante la iteración.

La barrera

El guía del tour: nadie avanza hasta que llegue todo el grupo.

Antes de intercambiar las placas, todos los hilos deben terminar. La barrera hace esperar a los rápidos, así que el más lento marca el ritmo. Activa "El hilo 0 va más lento" o el reparto desbalanceado y mira cuánto tiempo se pierde esperando.

¿Cuándo parar?

Cada hilo guarda el cambio más grande de su franja. Después de la barrera se toma el máximo de todos (una reducción) y, si es menor que ε, la placa llegó al equilibrio.

Estabilidad: r ≤ 1/4

r mide qué tan grande es cada paso en el tiempo. Si r pasa de 1/4, cada iteración amplifica el error en vez de suavizarlo y aparecen ondas que crecen sin control. Sube r a 0.30 y míralo explotar.

Lo que pasa en una máquina de verdad

Medimos los motores en C de este proyecto con 4 hilos (resultado idéntico al serial en todos los casos):

Placapthreads, barrera que girapthreads, barrera que duermeOpenMP
120 × 1201.73x0.19x2.52x
400 × 4003.36x1.40x3.50x
1000 × 10001.42x1.60x1.37x

Dos lecciones. En placas chicas cada iteración dura microsegundos, y una barrera que duerme al hilo en el sistema operativo cuesta más que el trabajo: con ella, 4 hilos son 5 veces más lentos que 1. En placas enormes los datos ya no caben en la caché y todos quedan esperando a la memoria: más hilos casi no ayudan.

El corazón del motor con pthreads

// Cada hilo: su franja, sus propios punteros, una barrera por iteración
static void* heat_worker(void* arg) {
  ...
  heat_partition(s->rows, s->threads, tid, &begin, &end);
  double* cur = s->grid_a;            // copia local de los punteros
  double* next = s->grid_b;

  for (long it = 0; it < s->max_iterations; ++it) {
    int parity = it & 1;               // dos juegos de casillas
    s->deltas[parity * P + tid].value = heat_step_rows(cur, next, cols, r, begin, end);

    spin_barrier_wait(&s->barrier, &sense);

    double global = 0.0;                  // todos calculan el mismo máximo
    for (int t = 0; t < P; ++t) global = fmax(global, s->deltas[parity * P + t].value);
    double* tmp = cur; cur = next; next = tmp;
    if (global <= s->epsilon) break;   // misma decisión en todos los hilos
  }
}

La barrera que gira

void spin_barrier_wait(spin_barrier_t* b, int* local) {
  int mine = !*local; *local = mine;
  if (atomic_fetch_sub(&b->remaining, 1) == 1) {
    atomic_store(&b->remaining, b->total);
    atomic_store(&b->sense, mine);   // abre
  } else {
    while (atomic_load(&b->sense) != mine)
      if (++spins > 2000) sched_yield();
  }
}

Lo mismo con OpenMP

#pragma omp parallel
while (!done) {
  #pragma omp for schedule(static) reduction(max:step)
  for (int i = 1; i < rows - 1; ++i)
    step = fmax(step, heat_step_rows(cur, next, cols, r, i, i + 1));
  #pragma omp single
  { swap(&cur, &next); done = step <= eps; step = 0; }
}

Consejos

  • Compara siempre contra la versión serial celda por celda: con doble búfer deben ser idénticas.
  • Una barrera por iteración basta si la reducción usa dos juegos de casillas (par e impar).
  • Separa las casillas de cada hilo en líneas de caché distintas para evitar el false sharing.
  • Nunca imprimas dentro del ciclo de cálculo, y mide con CLOCK_MONOTONIC.
  • Busca condiciones de carrera con -fsanitize=thread.

Pruébalo en tu máquina

cd core-c-engines/concurrency/simulations/heat-diffusion
make -C pthreads && make -C openmp
./pthreads/bin/heat_pthreads 400 400 4 0.05 --compare
./pthreads/bin/heat_pthreads 400 400 4 0.05 --compare --sleep-barrier
./openmp/bin/heat_openmp 400 400 4 0.05 --compare

--compare corre también la versión serial e imprime la aceleración y si las placas son idénticas.

Qué mejoró respecto al motor original

  • Las tres versiones comparten una sola biblioteca (common/heat.c), y las pruebas usan esa misma biblioteca en vez de una copia.
  • Una barrera por iteración en lugar de dos, sin que el hilo 0 tenga que hacer la reducción solo.
  • Barrera que gira con atómicos de C11: de 0.19x a 1.73x en una placa de 120 × 120.
  • Cada hilo usa sus propios punteros y casillas alineadas a 64 bytes, sin false sharing.
  • Una "puerta de salida" hace que un pthread_create fallido no deje hilos atrapados en la barrera.
  • OpenMP crea los hilos una sola vez, no en cada iteración.
  • Validación de entradas (r estable, tamaños, hilos) y verificación sin carreras con ThreadSanitizer.
El cliente ejecuta
$ ./client
Método HTTP
Cliente [2001:db8::2]:52814
TCP CLOSED

                
Servidor [2001:db8::1]:1234
TCP LISTEN
disk.bin

                
Paso
Paquete en el cable
IPv6
TCP
HTTP

                    
línea de petición / estadoencabezados\r\n (fin de línea)cuerpo
Nada viaja por el cable en este paso: el trabajo ocurre dentro de una computadora.

¿Qué acabas de ver?

Dos programas en dos computadoras: el cliente pide una figura y el servidor la busca en su disco y la devuelve. Por debajo hay dos protocolos trabajando juntos: TCP se encarga de que los bytes lleguen completos y en orden, y HTTP define qué dicen esos bytes.

La vida de un socket en el servidor

socket()bind(1234)listen()accept()Read / WriteClose()

El cliente solo hace socket() → connect() → Write → Read → Close().

El saludo de tres vías

"¿Me oyes?" "Sí, ¿y tú a mí?" "Sí." Y ya pueden hablar.

SYN, SYN-ACK y ACK sincronizan los números de secuencia de ambos lados. Desde ahí, cada byte enviado tiene un número, y el ack dice "ya recibí todo hasta aquí".

Anatomía de HTTP

Un mensaje HTTP es texto plano: una primera línea (método y ruta, o código de estado), encabezados Nombre: valor, una línea vacía y después el cuerpo. Cada línea termina en \r\n.

Tu servidor no envía Content-Length en las figuras: el cliente sabe que terminó cuando el servidor cierra la conexión (Connection: close).

Un hilo por cliente

Cada cliente que entra recibe su propio mesero.

El hilo principal solo hace accept(). Por cada conexión crea un std::thread(task, client) que lee, responde y cierra. Así un cliente lento no bloquea a los demás. Con miles de clientes conviene un pool fijo de hilos.

Segmentos y MSS

TCP no manda mensajes, manda un flujo de bytes partido en segmentos. En Ethernet con IPv6 cada segmento lleva como máximo 1440 bytes de datos (el MSS). Pide charizard y verás la respuesta llegar en varios pedazos.

¿Quién cierra primero?

Tu servidor llama a Close() apenas termina de escribir, así que él envía el primer FIN. Por eso es el servidor el que queda en TIME_WAIT: un detalle que importa cuando reinicias el servidor y el puerto parece ocupado.

Un servidor TCP mínimo en C++

#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <cstring>

int main() {
  int server = socket(AF_INET, SOCK_STREAM, 0);
  int yes = 1;
  setsockopt(server, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof yes);

  sockaddr_in addr{};
  addr.sin_family = AF_INET;
  addr.sin_port = htons(1234);          // ¡orden de red!
  addr.sin_addr.s_addr = INADDR_ANY;
  bind(server, (sockaddr*)&addr, sizeof addr);
  listen(server, 16);

  while (true) {
    int client = accept(server, nullptr, nullptr);
    char buf[4096];
    ssize_t n = recv(client, buf, sizeof buf, 0);  // puede llegar incompleto
    const char* res = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhola\n";
    send(client, res, strlen(res), 0);
    close(client);                         // tu servidor: std::thread(task, client)
  }
}

Consejos

  • recv puede devolver menos bytes de los que esperas. Lee en ciclo hasta tener el mensaje completo.
  • Usa htons/ntohs para puertos: la red es big-endian.
  • SO_REUSEADDR evita el error «Address already in use» al reiniciar el servidor.
  • Revisa el valor de retorno de cada llamada. Un -1 ignorado es un bug escondido.

Para probar

  • curl -v http://localhost:1234/GET/batman muestra la petición y la respuesta completas.
  • nc localhost 1234 te deja escribir HTTP a mano.
  • Wireshark con el filtro tcp.port == 1234 te enseña cada paquete real.

Mis apuntes

Anota lo que quieras recordar de cada tema. Tus notas se guardan solas en este dispositivo.

Ley de Amdahl

¿Cuánto más rápido va tu programa si lo paralelizas? Depende de la parte que no se puede paralelizar.

85%
8 núcleos
Aceleración
3.90x
Límite
6.67x
Eficiencia
48.8%
FórmulaS(N) = 1 / ((1 − f) + f / N)

Sincronización y sockets de un vistazo

Hilos

// Mutex
pthread_mutex_lock(&m);  /* ... */  pthread_mutex_unlock(&m);
// Semáforo
sem_wait(&s);  /* ... */  sem_post(&s);
// Barrera
pthread_barrier_wait(&b);
// Atómico
atomic_fetch_add(&contador, 1);

Sockets

socketbindlistenacceptrecv/sendclose

El servidor hace las seis llamadas en ese orden; el cliente solo necesita socket, connect, send/recv y close.

Algoritmos de ordenamiento de un vistazo

AlgoritmoMejorPromedioPeorMemoriaEstableÚsalo cuando…
Quick SortO(n log n)O(n log n)O(n²)O(log n)NoNecesitas velocidad en memoria en el caso general.
Merge SortO(n log n)O(n log n)O(n log n)O(n)SíNecesitas estabilidad o garantía en el peor caso.
Heap SortO(n log n)O(n log n)O(n log n)O(1)NoLa memoria es muy limitada.
Shell SortO(n log n)O(n^(4/3))O(n²)O(1)NoArreglos medianos, sin recursión.
Insertion SortO(n)O(n²)O(n²)O(1)SíDatos casi ordenados o muy pocos (n ≤ 16).
Selection SortO(n²)O(n²)O(n²)O(1)NoEscribir es muy caro (hace pocas escrituras).
Bubble SortO(n)O(n²)O(n²)O(1)SíPara aprender; casi nunca en producción.
Radix SortO(n·k)O(n·k)O(n·k)O(n + k)SíEnteros o claves de largo fijo.