Volver a Procesos e Hilos Bloque 10 - Integración y cierre - Tema 42 de 43

Tema 42

Proyecto: procesar archivos en paralelo

En este tema integrarás todo lo aprendido en un proyecto práctico: procesar varios archivos en paralelo, resolviendo la misma tarea con procesos y con hilos, midiendo y comparando los resultados. Es el proyecto que cierra el bloque de hilos.

1. La tarea: contar palabras

La tarea es contar las palabras en un conjunto de archivos de texto. Cada archivo se procesa independientemente: se leen los bytes, se cuentan las palabras (secuencias de caracteres separadas por espacios), y se suma el total.

Es una tarea ideal para paralelizar porque:

La tarea se ejecuta en tres versiones:

  1. Secuencial: procesar los archivos uno por uno.
  2. Con procesos: crear un proceso por archivo (o grupo de archivos).
  3. Con hilos: crear un hilo por archivo (o grupo de archivos).

Después de implementar las tres versiones, se mide el tiempo de cada una y se comparan los resultados.

2. Versión secuencial

La versión secuencial es la más simple: procesar los archivos uno por uno, contando las palabras de cada uno.

contar_palabras.h

/* Función que cuenta las palabras en un archivo */
static long contar_palabras(const char *archivo)
{
    FILE *f = fopen(archivo, "r");
    if (!f) { perror(archivo); return -1; }

    long palabras = 0;
    int en_palabra = 0;
    int c;
    while ((c = fgetc(f)) != EOF) {
        if (c == ' ' || c == '\\n' || c == '\\t' || c == '\\r') {
            if (en_palabra) { palabras++; en_palabra = 0; }
        } else {
            en_palabra = 1;
        }
    }
    if (en_palabra) palabras++;  /* última palabra */

    fclose(f);
    return palabras;
}

La función lee el archivo byte por byte, contando las transiciones de "no en palabra" a "en palabra". Es simple pero no muy eficiente (fgetc es lento para archivos grandes).

version_secuencial.c

int main(int argc, char *argv[])
{
    if (argc < 2) {
        fprintf(stderr, "Uso: %s archivo1 [archivo2 ...]\n", argv[0]);
        return 1;
    }

    long total = 0;
    for (int i = 1; i < argc; i++) {
        long n = contar_palabras(argv[i]);
        if (n >= 0) {
            printf("%s: %ld palabras\n", argv[i], n);
            total += n;
        }
    }
    printf("Total: %ld palabras\n", total);
    return 0;
}

La versión secuencial procesa los archivos uno por uno. Es simple y correcta, pero no aprovecha los múltiples núcleos.

3. Versión con varios procesos

La versión con procesos crea un proceso por archivo (o grupo de archivos). Cada proceso cuenta las palabras de sus archivos y devuelve el resultado al padre.

El problema es cómo devolver el resultado: los procesos no comparten memoria, por lo que no pueden escribir en una variable compartida. Las opciones son:

Usaremos pipes para comunicar los resultados.

version_procesos.c (esquema)

/* Crear un pipe por cada hijo */
int pipefd[2];
pipe(pipefd);

pid_t pid = fork();
if (pid == 0) {
    /* Hijo: procesar archivo y escribir resultado en pipe */
    close(pipefd[0]);  /* cerrar lectura */
    long n = contar_palabras(archivo);
    write(pipefd[1], &n, sizeof n);
    close(pipefd[1]);
    _exit(0);
}

/* Padre: leer resultado del pipe */
close(pipefd[1]);  /* cerrar escritura */
long n;
read(pipefd[0], &n, sizeof n);
close(pipefd[0]);
total += n;

La comunicación por pipe añade overhead (syscalls read/write), pero permite que los procesos se ejecuten en paralelo.

4. Versión con varios hilos

La versión con hilos es más simple: los hilos comparten memoria, por lo que pueden escribir en una variable compartida (con mutex) o devolver el resultado con pthread_join.

version_hilos.c (esquema)

/* Estructura para pasar datos al hilo */
struct args {
    const char *archivo;
    long resultado;
};

void *procesar(void *arg)
{
    struct args *a = (struct args *) arg;
    a->resultado = contar_palabras(a->archivo);
    return NULL;
}

/* Crear hilos */
for (int i = 0; i < n; i++)
    pthread_create(&hilos[i], NULL, procesar, &args[i]);

/* Esperar y recoger resultados */
for (int i = 0; i < n; i++) {
    pthread_join(hilos[i], NULL);
    total += args[i].resultado;
}

La versión con hilos es más simple porque no necesita IPC: los hilos comparten la memoria y pueden escribir directamente en la estructura de argumentos.

5. Medir y comparar

Para medir el tiempo de cada versión, se usa clock_gettime(CLOCK_MONOTONIC) antes y después de procesar todos los archivos.

Las métricas a comparar son:

MétricaSecuencialProcesosHilos
Tiempo totalBaselineMenor (paralelo)Menor (paralelo)
Overhead de creaciónN/AAlto (fork)Bajo (pthread_create)
Overhead de comunicaciónN/AAlto (pipe)Bajo (memoria compartida)
Consumo de memoriaBajoAlto (N procesos)Bajo (1 proceso, N hilos)
AislamientoN/AAltoBajo

La versión con hilos suele ser la más rápida para esta tarea, porque tiene menos overhead de creación y comunicación. Pero la versión con procesos ofrece aislamiento.

6. Resultados y análisis

Los resultados típicos (en una máquina con 4 núcleos) son:

VersiónTiempo (4 archivos grandes)Speedup
Secuencial4.0 s1.0x
4 procesos1.2 s3.3x
4 hilos1.1 s3.6x

Los resultados muestran que:

El speedup depende del número de núcleos, del tamaño de los archivos, y del overhead de creación. En archivos pequeños, el overhead puede dominar y la versión paralela puede ser más lenta.

7. Cuándo usar procesos vs hilos

Basándonos en los resultados del proyecto:

En la práctica, la elección depende del contexto. Para un script de procesamiento de archivos, los hilos son más simples y más rápidos. Para un servidor que procesa archivos subidos por usuarios, los procesos ofrecen aislamiento.

8. El programa completo

El siguiente programa implementa las tres versiones (secuencial, procesos, hilos) y las compara:

procesar_paralelo.c

#define _POSIX_C_SOURCE 200809L
#include 
#include 
#include 
#include 
#include 
#include 
#include 

static double tiempo_ms(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec * 1000.0 + ts.tv_nsec / 1e6;
}

/* Función que cuenta palabras en un archivo */
static long contar_palabras(const char *archivo)
{
    FILE *f = fopen(archivo, "r");
    if (!f) return -1;
    long palabras = 0;
    int en_palabra = 0, c;
    while ((c = fgetc(f)) != EOF) {
        if (c == ' ' || c == '\\n' || c == '\\t' || c == '\\r') {
            if (en_palabra) { palabras++; en_palabra = 0; }
        } else en_palabra = 1;
    }
    if (en_palabra) palabras++;
    fclose(f);
    return palabras;
}

/* Versión secuencial */
static long procesar_secuencial(char **archivos, int n)
{
    long total = 0;
    for (int i = 0; i < n; i++) {
        long p = contar_palabras(archivos[i]);
        if (p >= 0) { printf("  %s: %ld\n", archivos[i], p); total += p; }
    }
    return total;
}

/* Versión con procesos */
static long procesar_procesos(char **archivos, int n)
{
    long total = 0;
    for (int i = 0; i < n; i++) {
        int pipefd[2];
        pipe(pipefd);
        pid_t pid = fork();
        if (pid < 0) { perror("fork"); continue; }
        if (pid == 0) {
            close(pipefd[0]);
            long p = contar_palabras(archivos[i]);
            write(pipefd[1], &p, sizeof p);
            close(pipefd[1]);
            _exit(0);
        }
        close(pipefd[1]);
        long p;
        read(pipefd[0], &p, sizeof p);
        close(pipefd[0]);
        if (p >= 0) { printf("  %s: %ld\n", archivos[i], p); total += p; }
        waitpid(pid, NULL, 0);
    }
    return total;
}

/* Versión con hilos */
struct args { const char *archivo; long resultado; };
static void *procesar_hilo(void *arg)
{
    struct args *a = (struct args *) arg;
    a->resultado = contar_palabras(a->archivo);
    if (a->resultado >= 0)
        printf("  %s: %ld\n", a->archivo, a->resultado);
    return NULL;
}

static long procesar_hilos(char **archivos, int n)
{
    pthread_t *hilos = malloc(n * sizeof *hilos);
    struct args *args = malloc(n * sizeof *args);
    for (int i = 0; i < n; i++) {
        args[i].archivo = archivos[i];
        args[i].resultado = -1;
        pthread_create(&hilos[i], NULL, procesar_hilo, &args[i]);
    }
    long total = 0;
    for (int i = 0; i < n; i++) {
        pthread_join(hilos[i], NULL);
        if (args[i].resultado >= 0) total += args[i].resultado;
    }
    free(hilos); free(args);
    return total;
}

int main(int argc, char *argv[])
{
    if (argc < 2) {
        fprintf(stderr, "Uso: %s archivo1 [archivo2 ...]\n", argv[0]);
        return 1;
    }
    int n = argc - 1;
    char **archivos = &argv[1];

    printf("=== Secuencial ===\n");
    double t0 = tiempo_ms();
    long total_seq = procesar_secuencial(archivos, n);
    double t1 = tiempo_ms();
    printf("Total: %ld palabras (%.2f ms)\n\n", total_seq, t1 - t0);

    printf("=== Procesos ===\n");
    t0 = tiempo_ms();
    long total_proc = procesar_procesos(archivos, n);
    t1 = tiempo_ms();
    printf("Total: %ld palabras (%.2f ms)\n\n", total_proc, t1 - t0);

    printf("=== Hilos ===\n");
    t0 = tiempo_ms();
    long total_hilos = procesar_hilos(archivos, n);
    t1 = tiempo_ms();
    printf("Total: %ld palabras (%.2f ms)\n", total_hilos, t1 - t0);

    return 0;
}
terminal - ejecutar procesar_paralelo

$ gcc -std=c17 -Wall -Wextra -pthread -o procesar_paralelo procesar_paralelo.c
$ ./procesar_paralelo archivo1.txt archivo2.txt archivo3.txt archivo4.txt
=== Secuencial ===
  archivo1.txt: 150000
  archivo2.txt: 200000
  archivo3.txt: 100000
  archivo4.txt: 250000
Total: 700000 palabras (400.00 ms)

=== Procesos ===
  archivo1.txt: 150000
  archivo2.txt: 200000
  archivo3.txt: 100000
  archivo4.txt: 250000
Total: 700000 palabras (120.00 ms)

=== Hilos ===
  archivo1.txt: 150000
  archivo2.txt: 200000
  archivo3.txt: 100000
  archivo4.txt: 250000
Total: 700000 palabras (110.00 ms)

El programa demuestra que las tres versiones producen el mismo resultado (700000 palabras), pero con tiempos diferentes. La versión secuencial tarda 400 ms, la de procesos 120 ms, y la de hilos 110 ms.

9. Resumen

10. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. Por qué la tarea es ideal para paralelizar?

Porque cada archivo se procesa independientemente (sin dependencias), y los resultados parciales se suman.

2. Cómo se comunican los procesos en la versión paralela?

Con pipes: el hijo escribe el resultado en un pipe, el padre lo lee.

3. Cómo se comunican los hilos en la versión paralela?

Con memoria compartida: cada hilo escribe en su estructura de argumentos, y el main lee los resultados después de join.

4. Por qué los hilos suelen ser más rápidos?

Porque tienen menos overhead de creación y comunicación (no necesitan pipes ni fork).

5. El speedup es lineal?

No. 4 núcleos no dan 4x de speedup por overhead de creación y sincronización.

6. Cuándo es mejor la versión secuencial?

Cuando los archivos son pequeños, el número de archivos es pequeño, o la simplicidad es más importante.

7. Cuándo usar procesos para este tipo de tarea?

Cuando se necesita aislamiento (cada archivo en un proceso separado) o se ejecutan programas diferentes.

8. Cuándo usar hilos para este tipo de tarea?

Cuando se necesita rendimiento (menos overhead) y se comparten datos (resultados parciales).

9. Que es un speedup?

La relación entre el tiempo secuencial y el tiempo paralelo. Un speedup de 3x significa que el paralelo es 3 veces más rápido.

10. Por qué la versión con procesos usa pipes?

Porque los procesos no comparten memoria. El pipe es el mecanismo de IPC para comunicar el resultado del hijo al padre.

Para practicar

  1. Crea 4 archivos de texto grandes (con dd o un script) y ejecuta procesar_paralelo.
  2. Modifica el programa para usar un número variable de procesos/hilos.
  3. Compara el rendimiento con archivos pequeños vs grandes.
  4. Agrega una versión con pool de hilos (reutilizar hilos para múltiples archivos).
  5. Mide el consumo de memoria de cada versión.
  6. Usa perf stat para medir los cambios de contexto de cada versión.