Volver a Procesos e Hilos Bloque 9 - Procesos frente a hilos en la práctica - Tema 38 de 43

Tema 38

Servidores

Un servidor debe atender a muchos clientes simultáneamente. ¿Crea un proceso por conexión? ¿Un hilo? ¿Un pool de procesos o de hilos? En este tema verás los modelos de concurrencia en servidores, sus ventajas y desventajas, y cómo los servidores reales (Apache, Nginx) los implementan.

1. El problema de los servidores

Un servidor es un programa que atiende peticiones de clientes. El problema fundamental es la concurrencia: cómo atender a muchos clientes simultáneamente sin que uno bloquee a los demás.

Un servidor típico hace:

  1. Crear un socket: abrir un puerto y esperar conexiones.
  2. Aceptar una conexión: cuando un cliente se conecta, aceptar la conexión.
  3. Atender la petición: leer la petición, procesarla, y enviar la respuesta.
  4. Cerrar la conexión: liberar los recursos.

El paso 3 (atender la petición) puede tardar (leer de disco, consultar base de datos, etc.). Si el servidor atiende las peticiones secuencialmente, los clientes esperan. La solución es atender las peticiones concurrentemente.

El servidor es el caso de uso clásico de concurrencia

Los servidores son el ejemplo más importante de concurrencia. Casi todos los conceptos que hemos visto (procesos, hilos, fork, exec, señales, mutex) se aplican a los servidores. Este tema es la aplicación práctica de todo lo aprendido.

2. El modelo iterativo

El modelo más simple es el iterativo: el servidor atiende a un cliente a la vez, en secuencia.

C - servidor iterativo

int main(void)
{
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    bind(listen_fd, ...);
    listen(listen_fd, 5);

    while (1) {
        int conn_fd = accept(listen_fd, NULL, NULL);
        /* Atender al cliente */
        char buf[1024];
        ssize_t n = read(conn_fd, buf, sizeof buf);
        write(conn_fd, "Hola\n", 5);
        close(conn_fd);
    }
    return 0;
}

Ventajas: simple, sin problemas de concurrencia (no hay datos compartidos).

Desventajas: un cliente lento bloquea a todos los demás. No es adecuado para servidores de producción.

3. Un proceso por conexión

El modelo un proceso por conexión crea un proceso hijo para cada cliente. El padre acepta conexiones y crea hijos; cada hijo atiende a un cliente.

C - un proceso por conexión

while (1) {
    int conn_fd = accept(listen_fd, NULL, NULL);
    pid_t pid = fork();
    if (pid == 0) {
        /* Hijo: atender al cliente */
        close(listen_fd);  /* no necesita el socket de escucha */
        char buf[1024];
        ssize_t n = read(conn_fd, buf, sizeof buf);
        write(conn_fd, "Hola\n", 5);
        close(conn_fd);
        _exit(0);
    }
    close(conn_fd);  /* el padre no necesita el socket de conexión */
}

Ventajas:

Desventajas:

Este modelo es el que usa Apache en modo prefork (tradicionalmente) y PostgreSQL.

4. Un hilo por conexión

El modelo un hilo por conexión crea un hilo para cada cliente. El servidor principal acepta conexiones y crea hilos; cada hilo atiende a un cliente.

C - un hilo por conexión

void *atender_cliente(void *arg)
{
    int conn_fd = (int)(long) arg;
    char buf[1024];
    ssize_t n = read(conn_fd, buf, sizeof buf);
    write(conn_fd, "Hola\n", 5);
    close(conn_fd);
    return NULL;
}

while (1) {
    int conn_fd = accept(listen_fd, NULL, NULL);
    pthread_t hilo;
    pthread_create(&hilo, NULL, atender_cliente, (void *)(long) conn_fd);
    pthread_detach(hilo);  /* no esperar al hilo */
}

Ventajas:

Desventajas:

Este modelo es el que usan muchos servidores de alto rendimiento (Java, Node.js con workers).

5. Pool de procesos (prefork)

El modelo prefork crea un pool de procesos al inicio. Cada proceso atiende conexiones en un bucle. Cuando un proceso termina (por error o por límite), el padre crea otro para reemplazarlo.

C - pool de procesos

/* Crear pool de N procesos */
for (int i = 0; i < N; i++) {
    pid_t pid = fork();
    if (pid == 0) {
        /* Hijo: atender conexiones */
        while (1) {
            int conn_fd = accept(listen_fd, NULL, NULL);
            /* atender cliente */
            close(conn_fd);
        }
        _exit(0);
    }
}

/* Padre: monitorear y reemplazar */
while (1) {
    int status;
    pid_t pid = wait(&status);
    /* Crear un nuevo proceso para reemplazar */
    fork();  /* ... */
}

Ventajas:

Desventajas:

Este modelo es el que usa Apache en modo prefork.

6. Pool de hilos (prethread)

El modelo prethread es como prefork, pero con hilos: se crea un pool de hilos al inicio, y cada hilo atiende conexiones en un bucle.

C - pool de hilos

/* Crear pool de N hilos */
pthread_t hilos[N];
for (int i = 0; i < N; i++)
    pthread_create(&hilos[i], NULL, atender_conexiones, NULL);

/* Cada hilo atiende conexiones */
void *atender_conexiones(void *arg)
{
    while (1) {
        int conn_fd = accept(listen_fd, NULL, NULL);
        /* atender cliente */
        close(conn_fd);
    }
    return NULL;
}

Ventajas:

Desventajas:

Este modelo es el que usa Apache en modo worker y muchos servidores de alto rendimiento.

7. Comparación de modelos

ModeloCreaciónMemoriaAislamientoComunicaciónRendimiento
IterativoN/ABajaAltoN/ABajo (secuencial)
Proceso por conexiónCostosa (fork)AltaAltoIPCBajo
Hilo por conexiónRápidaBajaBajoDirectaAlto
Prefork (pool)N/A (pre-creados)AltaAltoIPCMedio
Prethread (pool)N/A (pre-creados)BajaBajoDirectaAlto
Event-drivenN/ABajaAltoN/AMuy alto

La elección depende de las necesidades: si se necesita aislamiento y seguridad, usar procesos. Si se necesita rendimiento, usar hilos. Si se necesita máxima eficiencia, usar event-driven.

8. El modelo de eventos (event-driven)

El modelo event-driven usa un solo hilo (o un pool pequeño) que maneja todas las conexiones con I/O multiplexing (select, poll, epoll). En lugar de crear un proceso o hilo por conexión, se registra cada conexión y se procesa cuando hay datos disponibles.

Este modelo es el más eficiente en memoria y CPU, porque no hay overhead de creación de procesos/hilos ni de cambios de contexto. Pero es más complejo de programar: el código debe ser no bloqueante y manejar estados.

C - servidor event-driven (simplificado)

/* Servidor event-driven con epoll */
int epoll_fd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);

while (1) {
    struct epoll_event events[MAX_EVENTS];
    int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
    for (int i = 0; i < nfds; i++) {
        if (events[i].data.fd == listen_fd) {
            /* Nueva conexión */
            int conn_fd = accept(listen_fd, NULL, NULL);
            ev.events = EPOLLIN;
            ev.data.fd = conn_fd;
            epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev);
        } else {
            /* Datos disponibles en una conexión */
            int conn_fd = events[i].data.fd;
            char buf[1024];
            ssize_t n = read(conn_fd, buf, sizeof buf);
            if (n <= 0) {
                close(conn_fd);
                epoll_ctl(epoll_fd, EPOLL_CTL_DEL, conn_fd, NULL);
            } else {
                write(conn_fd, "Hola\n", 5);
            }
        }
    }
}

El modelo event-driven es el más eficiente pero el más complejo. Se usa en Nginx, Node.js, y Redis.

Event-driven: máxima eficiencia

El modelo event-driven no crea procesos ni hilos por conexión. Un solo hilo (o un pool pequeño) maneja todas las conexiones con I/O multiplexing. Esto es lo más eficiente en memoria y CPU, pero requiere código no bloqueante.

9. Servidores reales: Apache, Nginx

Los servidores web reales usan diferentes modelos:

ServidorModelo por omisiónCaracterísticas
Apache (prefork)Pool de procesosUn proceso por conexión. Aislamiento alto, memoria alta. Bueno para CGI y módulos no thread-safe.
Apache (worker)Pool de hilosUn hilo por conexión. Memoria baja, rendimiento alto. Requiere módulos thread-safe.
Apache (event)Event-driven + hilosCombina event-driven con un pool de hilos. Máxima eficiencia para conexiones keep-alive.
NginxEvent-drivenUn proceso maestro + N procesos worker. Cada worker maneja miles de conexiones con epoll. Muy eficiente.
Node.jsEvent-driven (single-threaded)Un solo hilo con event loop. Muy eficiente para I/O, pero no para CPU.
RedisEvent-driven (single-threaded)Un solo hilo. Muy rápido para operaciones en memoria.

La tendencia actual es hacia el modelo event-driven (Nginx, Node.js), porque ofrece la máxima eficiencia. Pero el modelo de procesos (Apache prefork) sigue siendo útil cuando se necesita aislamiento o compatibilidad con módulos no thread-safe.

10. Un programa que demuestra un servidor simple

El siguiente programa es un servidor TCP simple que usa fork para atender cada conexión:

servidor_fork.c

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

void manejador_sigchld(int sig)
{
    (void) sig;
    while (waitpid(-1, NULL, WNOHANG) > 0);
}

int main(void)
{
    /* Ignorar SIGPIPE (cliente cierra conexión) */
    signal(SIGPIPE, SIG_IGN);

    /* Instalar manejador de SIGCHLD */
    struct sigaction sa;
    sa.sa_handler = manejador_sigchld;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
    sigaction(SIGCHLD, &sa, NULL);

    /* Crear socket de escucha */
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) { perror("socket"); return 1; }

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(8080);

    if (bind(listen_fd, (struct sockaddr *) &addr, sizeof addr) < 0) {
        perror("bind"); return 1;
    }
    if (listen(listen_fd, 5) < 0) {
        perror("listen"); return 1;
    }

    printf("Servidor escuchando en puerto 8080...\n");

    while (1) {
        int conn_fd = accept(listen_fd, NULL, NULL);
        if (conn_fd < 0) { perror("accept"); continue; }

        pid_t pid = fork();
        if (pid < 0) { perror("fork"); close(conn_fd); continue; }

        if (pid == 0) {
            /* Hijo: atender al cliente */
            close(listen_fd);
            char buf[1024];
            ssize_t n = read(conn_fd, buf, sizeof buf - 1);
            if (n > 0) {
                buf[n] = 0;
                printf("Hijo %d: recibido: %s", (int) getpid(), buf);
                write(conn_fd, "Hola desde el servidor\n", 23);
            }
            close(conn_fd);
            _exit(0);
        }

        close(conn_fd);  /* el padre no necesita el socket de conexión */
    }
    return 0;
}

El servidor crea un proceso por conexión con fork. El hijo atiende al cliente y termina. El padre recoge a los hijos con SIGCHLD + WNOHANG.

Se puede probar con:

terminal - probar el servidor

$ gcc -std=c17 -Wall -Wextra -o servidor_fork servidor_fork.c
$ ./servidor_fork &
Servidor escuchando en puerto 8080...
$ echo "Hola servidor" | nc localhost 8080
Hola desde el servidor
$ pkill servidor_fork

Este servidor demuestra el patrón fork + exec + SIGCHLD en un caso real. Es la base de muchos servidores Unix.

11. Resumen

12. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. Qué es un servidor iterativo?

Un servidor que atiende a un cliente a la vez, en secuencia. Simple pero lento para múltiples clientes.

2. Cuáles son las ventajas de un proceso por conexión?

Aislamiento (un error no afecta a los demás), seguridad (diferentes usuarios), simplicidad (sin mutex).

3. Cuáles son las ventajas de un hilo por conexión?

Rápido (creación de hilo es barata), memoria eficiente (espacio compartido), comunicación fácil.

4. Que es prefork?

Un modelo que crea un pool de procesos al inicio. Cada proceso atiende conexiones en un bucle. El padre reemplaza los que terminan.

5. Que es el modelo event-driven?

Un modelo que usa un solo hilo (o pool pequeño) con I/O multiplexing (epoll) para manejar todas las conexiones. Es el más eficiente.

6. Que modelo usa Nginx?

Event-driven: un proceso maestro + N procesos worker. Cada worker maneja miles de conexiones con epoll.

7. Por qué el servidor debe ignorar SIGPIPE?

Porque si el cliente cierra la conexión mientras el servidor escribe, el núcleo envía SIGPIPE, que por omisión termina el proceso. Ignorarlo evita que el servidor muera.

8. Por qué el servidor debe manejar SIGCHLD?

Para recoger a los hijos terminados y evitar zombis. Se usa SIGCHLD + WNOHANG en un bucle.

9. Que es epoll?

Un mecanismo de I/O multiplexing de Linux que permite esperar eventos en múltiples descriptores de archivo de forma eficiente.

10. Cuál es la tendencia actual en servidores?

Hacia el modelo event-driven (Nginx, Node.js), que ofrece la máxima eficiencia en memoria y CPU.

Para practicar

  1. Compila y ejecuta servidor_fork.c. Prueba con nc o telnet.
  2. Modifica el servidor para usar hilos en lugar de procesos.
  3. Escribe un servidor event-driven con epoll.
  4. Compara el rendimiento de los tres modelos con un benchmark (ab, wrk).
  5. Investiga cómo Apache configura sus MPM (prefork, worker, event).
  6. Escribe un servidor que implemente keep-alive (conexiones persistentes).