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:
- Crear un socket: abrir un puerto y esperar conexiones.
- Aceptar una conexión: cuando un cliente se conecta, aceptar la conexión.
- Atender la petición: leer la petición, procesarla, y enviar la respuesta.
- 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.
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.
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.
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:
- Aislamiento: si un hijo falla, no afecta a los demás.
- Seguridad: cada proceso puede ejecutarse con diferentes usuarios.
- Simplicidad: no hay datos compartidos, no se necesitan mutex.
Desventajas:
- Costo de creación: fork es costoso (10-50 us por conexión).
- Memoria: cada proceso tiene su propio espacio de direcciones.
- Comunicación: compartir datos entre procesos requiere IPC.
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.
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:
- Rápido: crear un hilo es más rápido que crear un proceso.
- Memoria eficiente: los hilos comparten el espacio de direcciones.
- Comunicación fácil: compartir datos entre hilos es directo.
Desventajas:
- Sin aislamiento: un error en un hilo afecta a todos.
- Seguridad: todos los hilos tienen los mismos permisos.
- Sincronización: se necesitan mutex para datos compartidos.
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.
/* 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:
- Sin overhead de creación: los procesos ya existen cuando llega la conexión.
- Aislamiento: cada proceso es independiente.
- Robustez: si un proceso falla, el padre crea otro.
Desventajas:
- Memoria: N procesos usan N veces la memoria.
- Comunicación: compartir datos requiere IPC.
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.
/* 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:
- Sin overhead de creación: los hilos ya existen.
- Memoria eficiente: los hilos comparten el espacio de direcciones.
- Comunicación fácil: compartir datos es directo.
Desventajas:
- Sin aislamiento: un error en un hilo afecta a todos.
- Sincronización: se necesitan mutex para datos compartidos.
Este modelo es el que usa Apache en modo worker y muchos servidores de alto rendimiento.
7. Comparación de modelos
| Modelo | Creación | Memoria | Aislamiento | Comunicación | Rendimiento |
|---|---|---|---|---|---|
| Iterativo | N/A | Baja | Alto | N/A | Bajo (secuencial) |
| Proceso por conexión | Costosa (fork) | Alta | Alto | IPC | Bajo |
| Hilo por conexión | Rápida | Baja | Bajo | Directa | Alto |
| Prefork (pool) | N/A (pre-creados) | Alta | Alto | IPC | Medio |
| Prethread (pool) | N/A (pre-creados) | Baja | Bajo | Directa | Alto |
| Event-driven | N/A | Baja | Alto | N/A | Muy 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.
/* 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.
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:
| Servidor | Modelo por omisión | Características |
|---|---|---|
| Apache (prefork) | Pool de procesos | Un proceso por conexión. Aislamiento alto, memoria alta. Bueno para CGI y módulos no thread-safe. |
| Apache (worker) | Pool de hilos | Un hilo por conexión. Memoria baja, rendimiento alto. Requiere módulos thread-safe. |
| Apache (event) | Event-driven + hilos | Combina event-driven con un pool de hilos. Máxima eficiencia para conexiones keep-alive. |
| Nginx | Event-driven | Un proceso maestro + N procesos worker. Cada worker maneja miles de conexiones con epoll. Muy eficiente. |
| Node.js | Event-driven (single-threaded) | Un solo hilo con event loop. Muy eficiente para I/O, pero no para CPU. |
| Redis | Event-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:
#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:
$ 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
- Los servidores atienden a muchos clientes simultáneamente.
- El modelo iterativo atiende a un cliente a la vez (simple pero lento).
- Un proceso por conexión ofrece aislamiento y seguridad, pero es costoso.
- Un hilo por conexión es más rápido y eficiente en memoria, pero sin aislamiento.
- Prefork y prethread crean un pool de procesos/hilos al inicio (sin overhead de creación).
- El modelo event-driven es el más eficiente (un hilo maneja todas las conexiones con epoll).
- Apache ofrece prefork, worker y event. Nginx usa event-driven.
- El servidor debe manejar SIGCHLD para recoger a los hijos terminados.
- El servidor debe ignorar SIGPIPE para no terminar si el cliente cierra la conexión.
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
- Compila y ejecuta servidor_fork.c. Prueba con nc o telnet.
- Modifica el servidor para usar hilos en lugar de procesos.
- Escribe un servidor event-driven con epoll.
- Compara el rendimiento de los tres modelos con un benchmark (ab, wrk).
- Investiga cómo Apache configura sus MPM (prefork, worker, event).
- Escribe un servidor que implemente keep-alive (conexiones persistentes).