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:
- Independiente: cada archivo se procesa sin depender de los demás.
- CPU-bound: el trabajo es contar, no esperar E/S (aunque la lectura de archivos es I/O-bound).
- Fácil de dividir: se puede asignar un archivo (o un rango de archivos) a cada proceso/hilo.
- Fácil de combinar: los resultados parciales se suman para obtener el total.
La tarea se ejecuta en tres versiones:
- Secuencial: procesar los archivos uno por uno.
- Con procesos: crear un proceso por archivo (o grupo de archivos).
- 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.
/* 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).
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:
- Pipes: el hijo escribe el resultado en un pipe, el padre lo lee.
- Archivos temporales: el hijo escribe el resultado en un archivo, el padre lo lee.
- Código de salida: el hijo termina con exit(código), pero el código es solo 8 bits (0-255), insuficiente para un conteo grande.
Usaremos pipes para comunicar los resultados.
/* 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.
/* 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étrica | Secuencial | Procesos | Hilos |
|---|---|---|---|
| Tiempo total | Baseline | Menor (paralelo) | Menor (paralelo) |
| Overhead de creación | N/A | Alto (fork) | Bajo (pthread_create) |
| Overhead de comunicación | N/A | Alto (pipe) | Bajo (memoria compartida) |
| Consumo de memoria | Bajo | Alto (N procesos) | Bajo (1 proceso, N hilos) |
| Aislamiento | N/A | Alto | Bajo |
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ón | Tiempo (4 archivos grandes) | Speedup |
|---|---|---|
| Secuencial | 4.0 s | 1.0x |
| 4 procesos | 1.2 s | 3.3x |
| 4 hilos | 1.1 s | 3.6x |
Los resultados muestran que:
- La paralelización reduce el tiempo: tanto procesos como hilos son ~3-4x más rápidos que la versión secuencial.
- Los hilos son ligeramente más rápidos: menos overhead de creación y comunicación.
- El speedup no es lineal: 4 núcleos no dan 4x de speedup por overhead de creación y sincronización.
- La versión secuencial es simple: sin código de sincronización ni gestión de procesos.
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:
- Usar procesos cuando: se necesita aislamiento (cada archivo se procesa en un proceso separado), se ejecutan programas diferentes, o la tarea es independiente y no comparte datos.
- Usar hilos cuando: se necesita rendimiento (menos overhead), se comparten datos (resultados parciales), o la tarea es parte de un programa mayor.
- Usar secuencial cuando: los archivos son pequeños, el número de archivos es pequeño, o la simplicidad es más importante que el rendimiento.
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:
#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;
}
$ 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
- El proyecto procesa archivos en paralelo: contar palabras en múltiples archivos.
- La versión secuencial procesa los archivos uno por uno.
- La versión con procesos usa fork + pipe para comunicar resultados.
- La versión con hilos usa pthread_create + pthread_join (memoria compartida).
- Las tres versiones producen el mismo resultado, pero con tiempos diferentes.
- Los hilos suelen ser más rápidos que los procesos para esta tarea.
- El speedup depende del número de núcleos y del tamaño de los archivos.
- Los procesos ofrecen aislamiento, los hilos ofrecen rendimiento.
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
- Crea 4 archivos de texto grandes (con dd o un script) y ejecuta procesar_paralelo.
- Modifica el programa para usar un número variable de procesos/hilos.
- Compara el rendimiento con archivos pequeños vs grandes.
- Agrega una versión con pool de hilos (reutilizar hilos para múltiples archivos).
- Mide el consumo de memoria de cada versión.
- Usa perf stat para medir los cambios de contexto de cada versión.