En la lección anterior escribiste ClienteCatalogo, que habla BTCP/1 perfectamente. Su único problema es que no tiene con quién hablar: el servidor eras tú, tecleando respuestas a mano en un nc -l 9090. Esta lección construye el otro extremo.
ServerSocket es la clase que permite a un programa esperar conexiones en lugar de iniciarlas. Es una inversión completa del papel: el cliente sabe a dónde va y toma la iniciativa; el servidor no sabe quién vendrá ni cuándo, se queda escuchando en un puerto conocido y reacciona.
Y aquí es donde el módulo 8 deja de ser teoría. Un servidor que atiende a un cliente cada vez es inútil: mientras Marta consulta el catálogo, Diego espera. La solución —un pool acotado de hilos, con nombres para los logs, apagado en dos fases y estado compartido protegido por estructuras concurrentes— es exactamente lo que aprendiste en el módulo 8, aplicado al problema para el que se diseñó. El CatalogoConcurrente con su ConcurrentHashMap llevaba dos módulos esperando este momento.
Al terminar, el servidor de catálogo de BiblioTech estará funcionando: aceptará a Marta, a Diego y a Nuria a la vez, validará todo lo que llegue por la red, responderá con códigos, expulsará a los clientes inactivos, limitará las conexiones simultáneas y se apagará ordenadamente. Y podrás hablar con él desde telnet.
Contenido
ServerSocket: escuchar en un puerto- El
bind, elbacklogy la dirección de escucha accept(): el método que entrega conexionesBindException: Address already in useysetReuseAddress- El servidor secuencial y la demostración de su límite
- El servidor concurrente: un hilo por conexión
- La solución correcta:
ExecutorService - Cerrar el
ServerSocketpara desbloquear elaccept - Estado compartido entre conexiones
- BiblioTech: el servidor de catálogo completo
- Validación de la entrada: nunca confiar en la red
- Probarlo con
telnety connc - Apagado ordenado con shutdown hook
- Límites del modelo y qué viene después
- Errores Comunes y Consejos
- Ejercicios
ServerSocket: escuchar en un puerto
ServerSocket: escuchar en un puertoUn ServerSocket no es un socket de conversación. No se lee ni se escribe en él. Su único trabajo es esperar conexiones entrantes y, por cada una, fabricar un Socket normal —de los de 09-02— por el que sí se conversa.
import java.net.ServerSocket;
import java.net.Socket;
// El constructor hace el "bind": reserva el puerto 9090 para este proceso.
try (ServerSocket servidor = new ServerSocket(9090)) {
System.out.println("Escuchando en el puerto " + servidor.getLocalPort());
// accept() BLOQUEA hasta que llega una conexion.
// Cuando vuelve, devuelve un Socket YA CONECTADO al cliente.
Socket conexion = servidor.accept();
System.out.println("Cliente conectado desde " + conexion.getRemoteSocketAddress());
// A partir de aqui, 'conexion' es exactamente el Socket de 09-02:
// getInputStream(), getOutputStream(), setSoTimeout(), close()...
}La distinción entre los dos objetos es la clave de toda la lección:
ServerSocket |
Socket (el que devuelve accept) |
|
|---|---|---|
| Para qué sirve | Esperar conexiones | Conversar con un cliente concreto |
| Cuántos hay | Uno por servicio | Uno por cliente conectado |
| Puerto | El conocido (9090) | El mismo 9090 en el lado local; el cliente tiene el suyo efímero |
| Flujos | No tiene | getInputStream() / getOutputStream() |
| Se cierra | Al parar el servicio | Al terminar cada conversación |
sequenceDiagram
participant SO as Sistema operativo
participant SS as ServerSocket (9090)
participant Srv as Codigo del servidor
participant C as Cliente
Srv->>SS: new ServerSocket(9090)
Note over SS,SO: bind: el puerto 9090 queda reservado<br/>listen: el SO empieza a aceptar SYN
Srv->>SS: accept()
Note over Srv: BLOQUEADO esperando
C->>SO: SYN
SO->>C: SYN + ACK
C->>SO: ACK
Note over SO: Conexion ya establecida.<br/>Va a la cola de pendientes (backlog).
SO-->>SS: hay una conexion en la cola
SS-->>Srv: devuelve un Socket conectado
Note over Srv: A partir de aqui, E/S normal
Srv->>C: 200 BIBLIOTECH BTCP/1
C->>Srv: CONSULTA 978-0000000001
Srv->>C: 200 OK ...
C->>Srv: SALIR
Srv->>C: 221 ADIOS
Srv->>Srv: socket.close()
Note over Srv: Vuelve al accept() a por el siguiente
Fíjate en un detalle importantísimo del diagrama: el saludo de tres vías lo completa el sistema operativo, no tu código. Cuando tu accept() devuelve, la conexión ya estaba establecida desde antes. Eso significa que un cliente puede conectar con éxito aunque tu servidor esté ocupado y tarde en llamar a accept() — quedará esperando en una cola. Esa cola es el backlog.
- El
bind, el backlog y la dirección de escucha
bind, el backlog y la dirección de escuchaServerSocket tiene varios constructores, y sus parámetros son exactamente las tres decisiones que hay que tomar:
// 1. Puerto. Lo minimo.
new ServerSocket(9090);
// 2. Puerto + backlog: tamano de la cola de conexiones pendientes.
new ServerSocket(9090, 100);
// 3. Puerto + backlog + direccion de escucha.
new ServerSocket(9090, 100, InetAddress.getByName("127.0.0.1"));
// 4. Sin conectar, para configurar antes del bind (lo veremos en el apartado 4).
ServerSocket s = new ServerSocket();
s.setReuseAddress(true);
s.bind(new InetSocketAddress(9090), 100);El backlog
Es el tamaño de la cola donde el sistema operativo guarda las conexiones ya establecidas que tu código aún no ha recogido con accept().
cola de pendientes (backlog = 3)
clientes --> [ conn ][ conn ][ conn ] --> accept() --> tu codigo
nuevos (uno cada vez)
Si la cola esta llena, el SO rechaza las conexiones nuevas
y el cliente recibe ConnectException: Connection refused.- Valor por defecto: 50.
- Si tu código atiende rápido y vuelve enseguida al
accept(), la cola casi nunca se llena. - Si tu código tarda mucho por conexión (servidor secuencial), la cola se llena y los clientes empiezan a ser rechazados como si el servidor no existiera.
- El sistema operativo puede recortar tu valor: en Linux está limitado por
net.core.somaxconn. Pedir 10.000 no garantiza 10.000.
El backlog es un amortiguador para ráfagas, no una solución a un servidor lento. Un backlog de 1000 en un servidor secuencial solo consigue que mil clientes esperen mucho en lugar de que novecientos cincuenta reciban un error rápido.
La dirección de escucha
El tercer parámetro decide por qué interfaces se aceptan conexiones, y es una decisión de seguridad:
| Dirección | Efecto |
|---|---|
Omitida o 0.0.0.0 |
Escucha en todas las interfaces: accesible desde la red |
127.0.0.1 |
Escucha solo en el bucle invertido: solo desde la propia máquina |
192.168.1.50 |
Escucha solo por esa interfaz concreta |
Durante el desarrollo, atarse a 127.0.0.1 es una buena costumbre: garantiza que nadie de la red puede tocar tu servidor a medio hacer. En producción, BiblioTech escuchará en 0.0.0.0 para que Marta y Diego lleguen desde sus puestos. Lo haremos configurable con el Configuracion/ReglasNegocio que ya lee bibliotech.properties.
El puerto 0
try (ServerSocket servidor = new ServerSocket(0)) {
int puertoReal = servidor.getLocalPort(); // por ejemplo, 43127
System.out.println("El sistema me ha dado el puerto " + puertoReal);
}Pedir el puerto 0 hace que el sistema asigne uno libre cualquiera. Es la técnica estándar en pruebas automatizadas: cada prueba levanta su servidor en un puerto que seguro está libre, sin chocar con otras pruebas ni con procesos del desarrollador. Lo verás en el módulo 11 con JUnit.
accept(): el método que entrega conexiones
accept(): el método que entrega conexionesCuatro cosas que hay que saber de este método:
- Bloquea indefinidamente hasta que hay una conexión disponible. Un hilo parado en
accept()no consume CPU, pero está parado. - Devuelve un
Socketya conectado. No hay que llamar aconnect()ni a nada: la conversación puede empezar. - Es interrumpible solo cerrando el
ServerSocket.Thread.interrupt()no lo desbloquea. Este detalle gobierna todo el apagado ordenado, y lo tratamos en el apartado 8. - Admite tiempo límite con
serverSocket.setSoTimeout(ms), que hace que lanceSocketTimeoutExceptionen lugar de esperar para siempre. Igual que en 09-02, elServerSocketsigue válido después.
servidor.setSoTimeout(1000); // sondeo cada segundo
while (ejecutando) {
try {
Socket conexion = servidor.accept();
atender(conexion);
} catch (SocketTimeoutException e) {
// Un segundo sin conexiones: aprovechamos para comprobar
// la bandera de parada y volvemos a esperar.
continue;
}
}Este patrón —el mismo que el del setSoTimeout de 09-02— es una alternativa al cierre del ServerSocket para el apagado. Es más suave pero menos inmediato: hasta un segundo de retraso al parar. Ambas técnicas se usan; en BiblioTech usaremos el cierre, que es instantáneo.
BindException: Address already in use y setReuseAddress
BindException: Address already in use y setReuseAddressEste es el primer error que vas a encontrar, garantizado, en cuanto pruebes el servidor dos veces seguidas:
Exception in thread "main" java.net.BindException: Address already in use
at java.base/sun.nio.ch.Net.bind0(Native Method)
at java.base/java.net.ServerSocket.bind(ServerSocket.java:395)
at java.base/java.net.ServerSocket.<init>(ServerSocket.java:262)Significa exactamente lo que dice: algo ya tiene reservado ese puerto. Hay dos causas muy distintas.
Causa 1: otro proceso lo tiene
Lo más habitual es una ejecución anterior de tu propio servidor que no cerraste. Se diagnostica en un segundo:
ss -tlnp | grep 9090
# LISTEN 0 50 0.0.0.0:9090 0.0.0.0:* users:(("java",pid=4711,fd=7))
kill 4711 # y si se resiste, kill -9 4711En macOS y en sistemas sin ss:
Causa 2: TIME_WAIT
Esta es más sutil y más frustrante. Cuando una conexión TCP se cierra, el extremo que cierra primero deja la conexión en estado TIME_WAIT durante un par de minutos. Es intencionado: sirve para que los paquetes retrasados de esa conexión no confundan a una conexión nueva con la misma cuádrupla.
El efecto es que puedes parar tu servidor y no poder volver a arrancarlo durante dos minutos, aunque ss no muestre ningún proceso escuchando.
La solución es SO_REUSEADDR:
// La forma correcta: crear SIN atar, configurar, y despues atar.
ServerSocket servidor = new ServerSocket();
servidor.setReuseAddress(true); // ANTES del bind
servidor.bind(new InetSocketAddress(9090), 100);setReuseAddress(true) debe fijarse antes del bind, y por eso hay que usar el constructor sin argumentos. Si usas new ServerSocket(9090) el bind ya ha ocurrido y llamar a setReuseAddress después no sirve de nada.
Aviso.
SO_REUSEADDRpermite atar un puerto que está enTIME_WAIT, pero no permite atar un puerto que otro proceso está escuchando activamente. Si sigues teniendoBindExceptionconsetReuseAddress(true), la causa es la número 1 y tocass -tlnp.
En BiblioTech, el ServerSocket se creará siempre con este patrón de tres pasos. Es una de esas cosas que no cuestan nada y ahorran mucha frustración.
- El servidor secuencial y la demostración de su límite
Empecemos por lo más simple que funciona, para ver por qué no basta.
package com.nexussoftware.bibliotech.red;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.logging.Logger;
/**
* Servidor de eco SECUENCIAL: atiende a un cliente cada vez.
* Sirve para demostrar el limite del modelo, no para usarlo.
*/
public class ServidorSecuencial {
private static final Logger LOG = Logger.getLogger(ServidorSecuencial.class.getName());
public static void main(String[] args) throws IOException {
ServerSocket servidor = new ServerSocket();
servidor.setReuseAddress(true);
servidor.bind(new InetSocketAddress(9090), 50);
LOG.info("Servidor secuencial escuchando en 9090");
try (servidor) {
while (true) {
// Se acepta UNA conexion...
Socket conexion = servidor.accept();
LOG.info(() -> "Conexion de " + conexion.getRemoteSocketAddress());
// ...y se atiende ENTERA antes de volver al accept().
// Mientras dura esta conversacion, nadie mas es atendido.
try (conexion) {
conexion.setSoTimeout(60_000);
atender(conexion);
} catch (IOException e) {
LOG.warning("Fallo atendiendo la conexion: " + e.getMessage());
}
LOG.info("Conexion terminada; vuelvo al accept()");
}
}
}
private static void atender(Socket conexion) throws IOException {
BufferedReader lector = new BufferedReader(
new InputStreamReader(conexion.getInputStream(), StandardCharsets.UTF_8));
PrintWriter escritor = new PrintWriter(
new java.io.BufferedWriter(
new java.io.OutputStreamWriter(conexion.getOutputStream(),
StandardCharsets.UTF_8)),
true);
escritor.println("200 SERVIDOR DE ECO. Escribe SALIR para terminar.");
String linea;
while ((linea = lector.readLine()) != null) {
if (linea.equalsIgnoreCase("SALIR")) {
escritor.println("221 ADIOS");
return;
}
escritor.println("ECO: " + linea);
}
// readLine() ha devuelto null: el cliente cerro sin despedirse.
}
}La demostración
Arranca el servidor y abre tres terminales con telnet localhost 9090:
TERMINAL 1 (primer cliente) TERMINAL 2 (segundo cliente)
------------------------------ ------------------------------
$ telnet localhost 9090 $ telnet localhost 9090
Trying 127.0.0.1... Trying 127.0.0.1...
Connected to localhost. Connected to localhost.
200 SERVIDOR DE ECO. Escribe... _
hola (nada. No hay saludo.
ECO: hola La conexion TCP esta hecha,
pero el servidor no ha llamado
a accept() y no lee nada)El segundo cliente conecta, porque el sistema operativo completa el saludo de tres vías y lo deja en el backlog. Pero no recibe el saludo del protocolo ni respuesta a nada, porque el hilo único del servidor sigue metido en la conversación del primero.
Escribe SALIR en la terminal 1 y observa la terminal 2:
TERMINAL 1 TERMINAL 2
------------------------------ ------------------------------
SALIR 200 SERVIDOR DE ECO. Escribe...
221 ADIOS (¡ahora si! Al liberarse el hilo,
Connection closed. el accept() recoge esta conexion)Esta es la demostración exacta del problema. El log del servidor lo confirma:
INFO: Servidor secuencial escuchando en 9090
INFO: Conexion de /127.0.0.1:52310
INFO: Conexion terminada; vuelvo al accept()
INFO: Conexion de /127.0.0.1:52311Las dos conexiones se establecieron casi a la vez, pero el servidor las procesó una detrás de otra.
Por qué es inaceptable
| Problema | Consecuencia |
|---|---|
| Un cliente lento bloquea a todos | Si Marta se va a comer con el telnet abierto, nadie más consulta |
El backlog se llena |
Con más de 50 esperando, los siguientes reciben ConnectException |
| Un cliente malicioso mata el servicio | Conectar y no enviar nada basta para dejar el servidor inútil hasta el setSoTimeout |
| No aprovecha la máquina | Un servidor de 16 núcleos usando uno |
Y fíjate en el tercer punto, porque es el más grave: un solo cliente que conecte y se calle deja el servicio caído durante 60 segundos. Sin setSoTimeout, para siempre. Un servidor secuencial es un servidor que cualquiera puede tumbar desde una terminal.
- El servidor concurrente: un hilo por conexión
La solución obvia, y el primer paso correcto: por cada conexión aceptada, lanzar un hilo que la atienda, y volver inmediatamente al accept().
while (true) {
Socket conexion = servidor.accept();
// Un hilo nuevo por conexion. El bucle vuelve al accept() al instante.
Thread hilo = new Thread(() -> {
try (conexion) {
atender(conexion);
} catch (IOException e) {
LOG.warning("Fallo atendiendo: " + e.getMessage());
}
}, "cliente-" + conexion.getPort());
hilo.start();
}Funciona. Con esto, las tres terminales de telnet son atendidas a la vez. Pero tiene un problema de escalabilidad serio, y es el mismo que ya viste en 08-05 cuando se explicó por qué existen los pools:
| Problema de "un hilo por conexión" | Detalle |
|---|---|
| Coste de memoria | Cada hilo de plataforma reserva pila propia: 512 KB - 1 MB. Mil clientes son 1 GB solo en pilas |
| Coste de creación | Crear un hilo cuesta del orden de decenas de microsegundos; con miles de conexiones cortas, se nota |
| Coste de planificación | Con muchos más hilos que núcleos, el sistema pasa tiempo cambiando de contexto en lugar de trabajar |
| Sin límite | El peor. Nada impide que diez mil conexiones creen diez mil hilos y tumben la JVM con OutOfMemoryError: unable to create new native thread |
El último punto convierte esto en una vulnerabilidad de denegación de servicio: un atacante abre conexiones en bucle y tu servidor se suicida creando hilos. No es teórico; es el ataque más barato que existe contra un servidor así.
- La solución correcta:
ExecutorService
ExecutorServiceAquí es donde el módulo 8 rinde de verdad. Un pool acotado resuelve los cuatro problemas de golpe: los hilos se reutilizan (sin coste de creación), son un número fijo (memoria acotada y planificación razonable) y, sobre todo, hay un límite: si llegan más conexiones de las que caben, se encolan o se rechazan, pero la JVM no se cae.
package com.nexussoftware.bibliotech.red;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Fabrica de hilos con nombres legibles. Un hilo llamado "bibliotech-cliente-3"
* en un volcado de hilos o en una linea de log vale mil veces mas que
* "pool-1-thread-3". Esto es 08-02 y 08-05 aplicados.
*/
public class FabricaHilosServidor implements ThreadFactory {
private final String prefijo;
private final AtomicInteger contador = new AtomicInteger(1);
public FabricaHilosServidor(String prefijo) {
this.prefijo = prefijo;
}
@Override
public Thread newThread(Runnable r) {
Thread hilo = new Thread(r, prefijo + "-" + contador.getAndIncrement());
// NO daemon: queremos que el apagado sea explicito y ordenado,
// no que la JVM mate conversaciones a medias al terminar el main.
hilo.setDaemon(false);
return hilo;
}
}ThreadFactory es una interfaz funcional con un único método, newThread, así que también puede escribirse como lambda (04-05); aquí va como clase con nombre porque necesita el contador como estado.
La creación del pool, con todos los parámetros explícitos:
// Pool acotado con ThreadPoolExecutor: control total sobre el comportamiento.
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, // hilos nucleo: siempre vivos
8, // maximo: acotado, sin sorpresas
60L, TimeUnit.SECONDS, // tiempo ocioso antes de morir
new LinkedBlockingQueue<>(100), // cola ACOTADA de tareas en espera
new FabricaHilosServidor("bibliotech-cliente"),
new ThreadPoolExecutor.AbortPolicy()); // que hacer si no cabeLas dos decisiones que más importan:
La cola debe ser acotada. Executors.newFixedThreadPool(8) usa por dentro una LinkedBlockingQueue sin límite. Eso significa que si llegan cien mil conexiones, las cien mil se encolan y te quedas sin memoria — has cambiado un OutOfMemoryError por hilos por un OutOfMemoryError por tareas encoladas. Una cola de 100 dice "puedo tener 8 conversaciones en curso y 100 esperando; a partir de ahí, rechazo".
La política de rechazo debe ser consciente. Cuando el pool está lleno y la cola también:
| Política | Qué hace | Cuándo usarla |
|---|---|---|
AbortPolicy (por defecto) |
Lanza RejectedExecutionException |
Cuando quieres rechazar explícitamente y decírselo al cliente |
CallerRunsPolicy |
La ejecuta el hilo que llamó a submit |
Contrapresión: frena al aceptador. Peligroso en un servidor: el hilo del accept se pone a atender un cliente y deja de aceptar |
DiscardPolicy |
La tira en silencio | Casi nunca: pérdida silenciosa |
DiscardOldestPolicy |
Tira la más vieja de la cola | Casi nunca en servidores |
Para un servidor, AbortPolicy es lo correcto: capturas la excepción, respondes al cliente algo como 503 SERVIDOR SATURADO y cierras educadamente. Es infinitamente mejor que aceptar y no responder.
try {
pool.execute(() -> atenderConexion(conexion));
} catch (RejectedExecutionException e) {
// El servidor esta saturado. Se lo decimos al cliente y cerramos.
// Rechazar rapido es MEJOR que aceptar y no responder.
rechazarEducadamente(conexion, "503 SERVIDOR SATURADO");
}El diagrama del servidor concurrente
graph TD
A["Hilo principal<br/>bucle accept()"] -->|Socket 1| B["ExecutorService<br/>pool de 8 hilos"]
A -->|Socket 2| B
A -->|Socket 3| B
A -->|Socket N| B
B --> C["cliente-1<br/>conversacion BTCP"]
B --> D["cliente-2<br/>conversacion BTCP"]
B --> E["cliente-3<br/>conversacion BTCP"]
C --> F["CatalogoConcurrente<br/>ConcurrentHashMap"]
D --> F
E --> F
B -->|pool y cola llenos| G["RejectedExecutionException<br/>503 SERVIDOR SATURADO"]
El hilo principal no hace otra cosa que aceptar. Recoge la conexión, la entrega al pool y vuelve al accept() en microsegundos. Toda la conversación —que puede durar minutos— ocurre en un hilo del pool. Esa separación es lo que hace que un servidor escale.
Cuántos hilos
La regla de 08-05 sigue valiendo, y aquí es especialmente favorable. Un hilo que atiende una conexión pasa la inmensa mayoría del tiempo bloqueado esperando que el cliente escriba algo. No consume CPU. Por eso un servidor de red admite muchos más hilos que núcleos:
- Para trabajo intensivo en CPU:
núcleosonúcleos + 1. - Para trabajo con mucha espera de E/S (esto): entre
núcleos × 4ynúcleos × 20, según cuánto se espere. - Con BTCP/1 y una red local, unas decenas bastan para cientos de clientes esporádicos.
BiblioTech usará un pool configurable, con valor por defecto de 16 hilos, leído de bibliotech.properties.
- Cerrar el
ServerSocket para desbloquear el accept
ServerSocket para desbloquear el acceptAquí hay un detalle que sorprende a todo el mundo la primera vez:
accept()no responde aThread.interrupt().
Un hilo bloqueado en accept() ignora completamente la interrupción. La bandera de interrupción se marca, pero el hilo sigue esperando. El protocolo de cancelación cooperativa de 08-02, que funciona perfectamente con sleep, wait y BlockingQueue.take, no sirve aquí.
La única forma de desbloquear un accept() es cerrar el ServerSocket desde otro hilo. Al hacerlo, el accept() lanza inmediatamente una SocketException:
private volatile boolean ejecutando = true;
private ServerSocket servidor;
/** Bucle principal. Se ejecuta en el hilo aceptador. */
public void ejecutar() {
while (ejecutando) {
try {
Socket conexion = servidor.accept();
pool.execute(() -> atender(conexion));
} catch (SocketException e) {
// Puede ser un fallo real O nuestro propio cierre.
// La bandera 'ejecutando' distingue los dos casos:
// sin ella, un apagado normal aparece en el log como un error grave.
if (!ejecutando) {
LOG.info("ServerSocket cerrado: fin ordenado del bucle de aceptacion");
break;
}
LOG.log(Level.SEVERE, "Fallo inesperado en accept()", e);
break;
} catch (IOException e) {
// Un fallo aceptando UNA conexion no debe tumbar el servidor:
// se registra y se sigue aceptando.
LOG.log(Level.WARNING, "Error aceptando una conexion", e);
}
}
}
/** Se llama desde otro hilo (menu, shutdown hook, senal). */
public void parar() {
ejecutando = false; // 1. bandera volatile: se ve desde el otro hilo
cerrarSilenciosamente(servidor); // 2. esto desbloquea el accept() al instante
// 3. y despues, el apagado en dos fases del pool
}Los tres elementos son necesarios y ninguno sobra:
- La bandera
volatiledistingue el cierre intencionado de un fallo real. Sin ella, cada apagado limpio deja unSEVEREcon traza en el log, y acabas ignorando losSEVERE. - Cerrar el
ServerSocketes lo que desbloquea. La bandera sola no haría nada, porque el hilo está dentro delaccept()y no vuelve a mirarla. - El orden importa: primero la bandera, después el cierre. Al revés, hay una ventana en la que el
accept()lanza la excepción yejecutandotodavía estrue, y el apagado limpio se registra como error.
El apagado en dos fases del pool
Cerrar el ServerSocket impide nuevas conexiones, pero no toca las conversaciones en curso. Para esas, el apagado en dos fases de 08-05:
public void parar() {
ejecutando = false;
cerrarSilenciosamente(servidor);
// FASE 1: no se aceptan tareas nuevas, pero las que estan en curso terminan.
pool.shutdown();
try {
// Damos un plazo razonable para que las conversaciones acaben solas.
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
LOG.warning("Conversaciones aun activas tras 10 s: se fuerza el cierre");
// FASE 2: interrupcion de los hilos que queden.
pool.shutdownNow();
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
LOG.severe("El pool no ha terminado; hay hilos atascados");
}
}
} catch (InterruptedException e) {
// Nos han interrumpido esperando: forzamos y RESTAURAMOS la bandera.
pool.shutdownNow();
Thread.currentThread().interrupt(); // 08-02: nunca se traga la interrupcion
}
}Detalle importante y a menudo olvidado.
shutdownNow()interrumpe los hilos del pool, pero un hilo bloqueado ensocket.read()tampoco responde a la interrupción — igual queaccept(). Por eso el servidor ponesetSoTimeouten cada conexión de cliente: convierte el bloqueo eterno en unaSocketTimeoutExceptionperiódica, que es un punto donde el hilo puede comprobar su bandera de interrupción y salir. SinsetSoTimeout,shutdownNow()no consigue parar a un cliente callado y elawaitTerminationse agota. Esta es la razón real, y práctica, de por qué el tiempo límite de lectura no es opcional en un servidor.
- Estado compartido entre conexiones
Con un pool de 16 hilos atendiendo conexiones, 16 hilos acceden al catálogo a la vez. Esto no es un caso excepcional: es el funcionamiento normal del servidor.
Y aquí llega la recompensa de dos módulos de trabajo. CatalogoConcurrente, que escribiste en 08-06 con ConcurrentHashMap, CopyOnWriteArrayList y LongAdder, ya está preparado para esto. No hay que tocar una sola línea:
// Dentro del hilo que atiende a Marta, y a la vez dentro del que atiende a Diego,
// y a la vez dentro del que atiende a Nuria:
Material material = catalogo.buscarPorIsbn(isbn); // seguro: ConcurrentHashMap
catalogo.registrarConsulta(); // seguro: LongAdder| Estado compartido de BiblioTech | Estructura | Por qué es seguro |
|---|---|---|
| Catálogo de materiales | ConcurrentHashMap en CatalogoConcurrente |
Lecturas sin bloqueo, escrituras atómicas por segmento |
| Contadores de consultas | LongAdder en EstadisticasBiblioTech |
Incrementos atómicos con contención mínima |
| Registro de préstamos | ReadWriteLock en RegistroPrestamosSeguro |
Invariante entre dos mapas bajo un único candado |
| Lista de oyentes | CopyOnWriteArrayList |
Muchas lecturas, escrituras raras |
| Configuración | Objeto inmutable cargado al arrancar | Inmutable = seguro por construcción |
Lo que sí hay que vigilar:
- Las operaciones compuestas siguen sin ser atómicas.
if (catalogo.estaDisponible(isbn)) catalogo.marcarPrestado(isbn)tiene una carrera entre las dos llamadas: dos clientes pueden pasar elifa la vez y ambos prestar el mismo libro. La solución, ya conocida de 08-06, es exponer la operación completa como un método atómico del servicio (RegistroPrestamosSeguro.prestarSiDisponible(...)), no encadenar dos operaciones seguras. - Cada conexión debe tener su propio estado local. Los
BufferedReader,PrintWritery variables de la conversación son de esa conversación. Nunca campos de instancia del servidor compartidos entre hilos. Este error —usar un campo del servidor para el escritor de "el" cliente— produce respuestas que llegan al cliente equivocado, y es un bug espectacular de diagnosticar. - Los contadores de conexiones activas deben ser atómicos:
AtomicInteger.
- BiblioTech: el servidor de catálogo completo
Ahora sí. Todo junto.
package com.nexussoftware.bibliotech.red;
import com.nexussoftware.bibliotech.dominio.Material;
import com.nexussoftware.bibliotech.servicio.CatalogoConcurrente;
import com.nexussoftware.bibliotech.servicio.RegistroPrestamosSeguro;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Servidor del protocolo BTCP/1 de BiblioTech.
*
* - Un hilo aceptador que solo acepta y delega.
* - Un pool ACOTADO de hilos que atienden las conversaciones (modulo 8).
* - Estado compartido en CatalogoConcurrente (ConcurrentHashMap): ya era seguro.
* - Validacion estricta de todo lo que llega por la red.
* - setSoTimeout por conexion para expulsar clientes inactivos.
* - Apagado ordenado en dos fases.
*/
public class ServidorCatalogo implements AutoCloseable {
private static final Logger LOG = Logger.getLogger(ServidorCatalogo.class.getName());
// --- Limites defensivos: todos los valores que vienen de la red se acotan ---
/** Ninguna peticion BTCP/1 legitima pasa de esto. Evita el agotamiento de memoria. */
private static final int MAXIMO_LONGITUD_LINEA = 512;
/** Peticiones seguidas por conexion: evita que un cliente monopolice un hilo. */
private static final int MAXIMO_PETICIONES = 1_000;
/** Un ISBN de BiblioTech tiene esta forma y nada mas. */
private static final int MAXIMO_ISBN = 20;
private static final int MAXIMO_EMPLEADO = 60;
private final int puerto;
private final String direccionEscucha;
private final int hilos;
private final int colaEspera;
private final int limiteInactividadMs;
private final CatalogoConcurrente catalogo;
private final RegistroPrestamosSeguro prestamos;
private ServerSocket servidor;
private ThreadPoolExecutor pool;
/** volatile: la escribe el hilo que para, la lee el hilo aceptador (08-04). */
private volatile boolean ejecutando = false;
private final AtomicInteger conexionesActivas = new AtomicInteger();
private final AtomicLong conexionesTotales = new AtomicLong();
private final AtomicLong peticionesAtendidas = new AtomicLong();
private final AtomicLong conexionesRechazadas = new AtomicLong();
public ServidorCatalogo(int puerto, String direccionEscucha, int hilos,
int colaEspera, int limiteInactividadMs,
CatalogoConcurrente catalogo,
RegistroPrestamosSeguro prestamos) {
this.puerto = puerto;
this.direccionEscucha = direccionEscucha;
this.hilos = hilos;
this.colaEspera = colaEspera;
this.limiteInactividadMs = limiteInactividadMs;
this.catalogo = catalogo;
this.prestamos = prestamos;
}
// =================================================================
// Arranque
// =================================================================
/** Prepara el socket y el pool. No bloquea. */
public void arrancar() throws IOException {
// Patron de tres pasos: crear sin atar, configurar, atar.
// setReuseAddress DEBE ir antes del bind, o no sirve de nada.
servidor = new ServerSocket();
servidor.setReuseAddress(true);
servidor.bind(new InetSocketAddress(direccionEscucha, puerto), colaEspera);
ThreadFactory fabrica = new ThreadFactory() {
private final AtomicInteger n = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
// Nombres legibles: en un volcado de hilos y en cada linea
// de log sabras exactamente quien hace que (08-02).
Thread h = new Thread(r, "bibliotech-cliente-" + n.getAndIncrement());
h.setDaemon(false);
return h;
}
};
pool = new ThreadPoolExecutor(
hilos, hilos,
60L, TimeUnit.SECONDS,
// Cola ACOTADA: newFixedThreadPool usa una sin limite y eso
// convierte una saturacion en un OutOfMemoryError.
new LinkedBlockingQueue<>(colaEspera),
fabrica,
// Rechazar rapido y decirselo al cliente es mejor que
// aceptar y no responder.
new ThreadPoolExecutor.AbortPolicy());
ejecutando = true;
LOG.info(() -> "ServidorCatalogo BTCP/1 escuchando en "
+ direccionEscucha + ":" + puerto
+ " (pool=" + hilos + ", cola=" + colaEspera + ")");
}
/** Bucle de aceptacion. Bloquea hasta que se llama a close(). */
public void ejecutar() {
while (ejecutando) {
Socket conexion = null;
try {
conexion = servidor.accept(); // BLOQUEA aqui
// Tiempo limite de INACTIVIDAD por conexion. Es lo que expulsa
// a los clientes que conectan y se callan, y ademas lo que
// permite que shutdownNow() consiga parar los hilos.
conexion.setSoTimeout(limiteInactividadMs);
conexion.setTcpNoDelay(true); // protocolo interactivo: sin Nagle
final Socket aceptada = conexion;
conexionesTotales.incrementAndGet();
// El hilo aceptador NO atiende: delega y vuelve al accept().
pool.execute(() -> atenderConexion(aceptada));
} catch (RejectedExecutionException e) {
// Pool y cola llenos: el servidor esta saturado.
conexionesRechazadas.incrementAndGet();
LOG.warning("Servidor saturado; conexion rechazada");
rechazar(conexion, "503 SERVIDOR SATURADO");
} catch (SocketException e) {
// Nuestro propio close() O un fallo real. La bandera decide.
if (!ejecutando) {
LOG.info("Bucle de aceptacion terminado ordenadamente");
return;
}
LOG.log(Level.SEVERE, "Fallo inesperado en accept()", e);
return;
} catch (IOException e) {
// Un fallo aceptando UNA conexion no tumba el servidor.
LOG.log(Level.WARNING, "Error aceptando una conexion", e);
cerrarSilenciosamente(conexion);
}
}
}
// =================================================================
// Atencion de una conexion (se ejecuta en un hilo del pool)
// =================================================================
private void atenderConexion(Socket conexion) {
String cliente = String.valueOf(conexion.getRemoteSocketAddress());
int activas = conexionesActivas.incrementAndGet();
LOG.info(() -> "[" + cliente + "] conectado (" + activas + " activas)");
// try-with-resources: el socket se cierra pase lo que pase.
try (conexion) {
BufferedReader lector = new BufferedReader(
new InputStreamReader(conexion.getInputStream(),
StandardCharsets.UTF_8));
PrintWriter escritor = new PrintWriter(
new BufferedWriter(
new OutputStreamWriter(conexion.getOutputStream(),
StandardCharsets.UTF_8)),
false); // sin autoFlush: vaciamos a mano tras cada respuesta
// El servidor habla primero, como especifica el protocolo.
responder(escritor, "200 BIBLIOTECH BTCP/1");
int peticiones = 0;
while (!Thread.currentThread().isInterrupted()) {
if (peticiones++ >= MAXIMO_PETICIONES) {
// Un cliente no puede quedarse con un hilo indefinidamente.
responder(escritor, "421 DEMASIADAS PETICIONES");
LOG.warning("[" + cliente + "] supero el limite de peticiones");
return;
}
String linea = leerLineaAcotada(lector);
if (linea == null) {
LOG.info(() -> "[" + cliente + "] cerro la conexion");
return;
}
peticionesAtendidas.incrementAndGet();
boolean seguir = procesar(linea, escritor, cliente);
if (!seguir) {
return;
}
}
} catch (SocketTimeoutException e) {
// El cliente conecto y se callo. Se le expulsa: el hilo vuelve al pool.
LOG.info(() -> "[" + cliente + "] expulsado por inactividad ("
+ limiteInactividadMs + " ms)");
} catch (LineaDemasiadoLargaException e) {
LOG.warning("[" + cliente + "] envio una linea de mas de "
+ MAXIMO_LONGITUD_LINEA + " caracteres; conexion cortada");
} catch (SocketException e) {
// "Connection reset": el cliente se cayo. Es normal, no es un error grave.
LOG.info(() -> "[" + cliente + "] conexion perdida: " + e.getMessage());
} catch (IOException e) {
LOG.log(Level.WARNING, "[" + cliente + "] fallo de E/S", e);
} catch (RuntimeException e) {
// Un fallo de programacion en el manejo de UN cliente no puede
// matar el hilo del pool ni afectar a los demas. Este catch
// es la frontera de errores de 06-07 aplicada a cada conexion.
LOG.log(Level.SEVERE, "[" + cliente + "] fallo inesperado", e);
} finally {
int quedan = conexionesActivas.decrementAndGet();
LOG.info(() -> "[" + cliente + "] desconectado (" + quedan + " activas)");
}
}
// =================================================================
// Procesado de una peticion
// =================================================================
/** Devuelve false si hay que cerrar la conexion tras esta peticion. */
private boolean procesar(String linea, PrintWriter escritor, String cliente) {
// Se separa el comando del resto por el PRIMER espacio: el nombre
// de empleado puede llevar espacios y no queremos partirlo.
String comando;
String resto;
int espacio = linea.indexOf(' ');
if (espacio < 0) {
comando = linea;
resto = "";
} else {
comando = linea.substring(0, espacio);
resto = linea.substring(espacio + 1).strip();
}
// Los comandos se comparan en mayusculas para ser tolerantes,
// pero el protocolo los documenta en mayusculas.
switch (comando.toUpperCase()) {
case "CONSULTA" -> {
if (!isbnValido(resto)) {
responder(escritor, "400 PETICION INVALIDA isbn no valido");
return true;
}
Material material = catalogo.buscarPorIsbn(resto);
if (material == null) {
responder(escritor, "404 NO ENCONTRADO " + resto);
} else {
responder(escritor, "200 OK " + formatear(material));
}
return true;
}
case "LISTA" -> {
if (!resto.isEmpty()) {
responder(escritor, "400 PETICION INVALIDA LISTA no admite argumentos");
return true;
}
List<Material> materiales = catalogo.todos();
responder(escritor, "201 LISTA " + materiales.size());
for (Material m : materiales) {
responder(escritor, formatear(m));
}
responder(escritor, ".");
return true;
}
case "PRESTAR" -> {
int sep = resto.indexOf(' ');
if (sep < 0) {
responder(escritor, "400 PETICION INVALIDA faltan argumentos");
return true;
}
String isbn = resto.substring(0, sep);
String empleado = resto.substring(sep + 1).strip();
if (!isbnValido(isbn) || !empleadoValido(empleado)) {
responder(escritor, "400 PETICION INVALIDA argumentos no validos");
return true;
}
if (catalogo.buscarPorIsbn(isbn) == null) {
responder(escritor, "404 NO ENCONTRADO " + isbn);
return true;
}
// OPERACION COMPUESTA ATOMICA: comprobar disponibilidad y prestar
// en una sola llamada al servicio. Encadenar dos operaciones
// seguras NO da una operacion segura (08-06).
boolean prestado = prestamos.prestarSiDisponible(isbn, empleado);
if (prestado) {
LOG.info(() -> "[" + cliente + "] prestamo de " + isbn
+ " a " + empleado);
responder(escritor, "200 OK prestamo registrado");
} else {
responder(escritor, "409 NO DISPONIBLE " + isbn);
}
return true;
}
case "SALIR" -> {
responder(escritor, "221 ADIOS");
return false; // cerrar la conexion
}
default -> {
// NO devolvemos el comando tal cual al cliente sin sanear:
// podria contener secuencias de escape de terminal.
responder(escritor, "400 PETICION INVALIDA comando desconocido: "
+ sanear(comando));
return true;
}
}
}
private String formatear(Material m) {
return m.getIsbn() + "|" + m.getTitulo() + "|"
+ m.getTipo() + "|" + m.estaDisponible();
}
// =================================================================
// E/S de bajo nivel del protocolo
// =================================================================
/** Escribe una linea del protocolo y VACIA. Sin flush, el cliente espera eternamente. */
private void responder(PrintWriter escritor, String linea) {
escritor.print(linea + "\n"); // \n explicito: el protocolo lo exige
escritor.flush(); // OBLIGATORIO
if (escritor.checkError()) {
// PrintWriter se traga las IOException; checkError es la unica pista.
LOG.fine("Fallo al escribir la respuesta; el cliente probablemente cerro");
}
}
/** Excepcion interna para una linea que excede el limite. */
private static class LineaDemasiadoLargaException extends IOException {
LineaDemasiadoLargaException(String m) {
super(m);
}
}
/**
* Lee una linea ACOTADA. BufferedReader.readLine() no tiene limite:
* un cliente malicioso puede enviar 100 MB sin un solo salto de linea
* y agotar la memoria del servidor. Esta es una defensa obligatoria
* en cualquier servidor expuesto.
*/
private String leerLineaAcotada(BufferedReader lector) throws IOException {
StringBuilder sb = new StringBuilder();
int c;
while ((c = lector.read()) != -1) {
if (c == '\n') {
// Aceptamos tambien \r\n quitando el retorno de carro final.
int fin = sb.length();
if (fin > 0 && sb.charAt(fin - 1) == '\r') {
sb.setLength(fin - 1);
}
return sb.toString();
}
if (sb.length() >= MAXIMO_LONGITUD_LINEA) {
throw new LineaDemasiadoLargaException(
"Linea de mas de " + MAXIMO_LONGITUD_LINEA + " caracteres");
}
sb.append((char) c);
}
// Fin de flujo. Si habia algo a medias, se descarta: no es una linea valida.
return null;
}
// =================================================================
// Validacion: NUNCA confiar en lo que llega por la red
// =================================================================
private boolean isbnValido(String isbn) {
if (isbn == null || isbn.isBlank() || isbn.length() > MAXIMO_ISBN) {
return false;
}
// Lista BLANCA de caracteres: solo digitos y guiones. Todo lo demas fuera.
// Una lista blanca es siempre mas segura que una lista negra.
for (int i = 0; i < isbn.length(); i++) {
char c = isbn.charAt(i);
if (!Character.isDigit(c) && c != '-') {
return false;
}
}
return true;
}
private boolean empleadoValido(String empleado) {
if (empleado == null || empleado.isBlank() || empleado.length() > MAXIMO_EMPLEADO) {
return false;
}
for (int i = 0; i < empleado.length(); i++) {
char c = empleado.charAt(i);
// Letras, digitos, espacios, guiones bajos y guiones. Nada de
// caracteres de control, que romperian el protocolo o el log.
if (!Character.isLetterOrDigit(c) && c != ' ' && c != '_' && c != '-') {
return false;
}
}
return true;
}
/**
* Sanea un texto que viene de la red antes de devolverlo o registrarlo.
* Sin esto, un cliente puede inyectar \n (una respuesta falsa en el
* protocolo) o secuencias de escape ANSI (que manipulan el terminal
* de quien lea el log). Es INYECCION DE LOG, un problema real.
*/
private String sanear(String texto) {
StringBuilder sb = new StringBuilder();
int limite = Math.min(texto.length(), 40);
for (int i = 0; i < limite; i++) {
char c = texto.charAt(i);
sb.append(Character.isISOControl(c) ? '?' : c);
}
return sb.toString();
}
// =================================================================
// Apagado ordenado
// =================================================================
@Override
public void close() {
if (!ejecutando) {
return;
}
LOG.info("Apagando ServidorCatalogo...");
// 1. Bandera ANTES del cierre: distingue el apagado limpio de un fallo.
ejecutando = false;
// 2. Cerrar el ServerSocket es lo UNICO que desbloquea accept().
// Thread.interrupt() no lo consigue.
cerrarSilenciosamente(servidor);
// 3. Apagado en dos fases del pool (08-05).
if (pool != null) {
pool.shutdown(); // no admite tareas nuevas; las actuales siguen
try {
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
LOG.warning("Conversaciones activas tras 10 s: se fuerza el cierre");
pool.shutdownNow(); // interrumpe los hilos
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
LOG.severe("Quedan hilos sin terminar");
}
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt(); // 08-02: restaurar la bandera
}
}
LOG.info(() -> String.format(
"ServidorCatalogo detenido. Conexiones: %d totales, %d rechazadas. "
+ "Peticiones atendidas: %d",
conexionesTotales.get(), conexionesRechazadas.get(),
peticionesAtendidas.get()));
}
private void rechazar(Socket conexion, String mensaje) {
if (conexion == null) {
return;
}
// Cortesia: decirle al cliente por que se le cierra, en lugar de
// cortarle sin explicacion. Cuesta dos lineas y ahorra soporte.
try (conexion) {
PrintWriter escritor = new PrintWriter(
new OutputStreamWriter(conexion.getOutputStream(),
StandardCharsets.UTF_8));
escritor.print(mensaje + "\n");
escritor.flush();
} catch (IOException e) {
LOG.fine("No se pudo notificar el rechazo: " + e.getMessage());
}
}
private void cerrarSilenciosamente(java.io.Closeable recurso) {
if (recurso != null) {
try {
recurso.close();
} catch (IOException ignorada) {
// Cerrando ya no hay nada que salvar.
}
}
}
// --- Metricas para el menu de BiblioTech ---
public int conexionesActivas() {
return conexionesActivas.get();
}
public long conexionesTotales() {
return conexionesTotales.get();
}
public long peticionesAtendidas() {
return peticionesAtendidas.get();
}
}La clase de arranque
package com.nexussoftware.bibliotech.presentacion;
import com.nexussoftware.bibliotech.persistencia.Configuracion;
import com.nexussoftware.bibliotech.red.ServidorCatalogo;
import com.nexussoftware.bibliotech.servicio.CatalogoConcurrente;
import com.nexussoftware.bibliotech.servicio.RegistroPrestamosSeguro;
import com.nexussoftware.bibliotech.infra.ConfiguracionLog;
import java.io.IOException;
import java.util.logging.Logger;
/** Arranque del servidor de catalogo de BiblioTech. */
public class ServidorBiblioTechApp {
private static final Logger LOG = Logger.getLogger(ServidorBiblioTechApp.class.getName());
public static void main(String[] args) throws IOException {
ConfiguracionLog.inicializar(); // el logger del modulo 6, ya configurado
// La configuracion sale de bibliotech.properties (07-07).
Configuracion config = Configuracion.cargar();
int puerto = config.entero("red.puerto", 9090);
String escucha = config.texto("red.escucha", "0.0.0.0");
int hilos = config.entero("red.hilos", 16);
int cola = config.entero("red.cola", 100);
int inactividad = config.entero("red.inactividad.ms", 60_000);
CatalogoConcurrente catalogo = CatalogoConcurrente.cargarDesdeFichero();
RegistroPrestamosSeguro prestamos = new RegistroPrestamosSeguro(catalogo);
ServidorCatalogo servidor = new ServidorCatalogo(
puerto, escucha, hilos, cola, inactividad, catalogo, prestamos);
servidor.arrancar();
// Shutdown hook: se ejecuta con Ctrl+C o con SIGTERM (que es lo que
// envia Docker, systemd o Kubernetes al parar un contenedor).
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
LOG.info("Senal de apagado recibida");
servidor.close();
}, "bibliotech-apagado"));
// Bloquea aqui hasta que el ServerSocket se cierre.
servidor.ejecutar();
LOG.info("Servidor terminado");
}
}
- Validación de la entrada: nunca confiar en la red
Merece la pena detenerse en esto, porque es la diferencia entre un ejercicio y un servidor que se puede poner en producción.
Todo lo que llega por un socket es entrada no confiable. No importa que sea la red interna de Nexus Software: esa red incluye el portátil de un becario, el móvil de un visitante y cualquier equipo comprometido. Un cliente puede ser un programa distinto del tuyo, una versión antigua, o alguien con nc y curiosidad.
Estas son las defensas que lleva ServidorCatalogo y qué ataque para cada una:
| Defensa | Ataque que evita |
|---|---|
MAXIMO_LONGITUD_LINEA con lectura acotada |
Un cliente envía 100 MB sin \n y agota la memoria del servidor |
MAXIMO_PETICIONES por conexión |
Un cliente monopoliza un hilo del pool indefinidamente |
setSoTimeout por conexión |
Clientes que conectan y se callan agotan el pool |
| Pool y cola acotados | Miles de conexiones simultáneas tumban la JVM |
isbnValido con lista blanca de caracteres |
Inyección de datos raros en el catálogo, en el log o en la respuesta |
sanear() antes de devolver o registrar |
Inyección de saltos de línea (respuestas falsas) y de escapes ANSI en el log |
Operación compuesta atómica (prestarSiDisponible) |
Dos clientes prestan el mismo libro simultáneamente |
catch (RuntimeException) por conexión |
Un fallo con un cliente mata el hilo y afecta a los demás |
Dos principios generales:
Lista blanca, nunca lista negra. isbnValido acepta solo dígitos y guiones. La alternativa —rechazar caracteres peligrosos— siempre se queda corta, porque la lista de caracteres peligrosos crece con cada nuevo contexto. Define lo que aceptas y rechaza el resto.
Sanear también lo que sale. Si devuelves al cliente el comando que envió, y ese comando contiene \n200 OK falso, has permitido que inyecte una respuesta en tu protocolo. Y si lo escribes en el log sin sanear, un atacante puede inyectar líneas de log falsas o secuencias de escape que manipulan el terminal del administrador que lo lea. Es el mismo tipo de problema que la inyección SQL, aplicado a otro contexto.
Sobre TLS. BTCP/1 viaja en claro: cualquiera con acceso al tramo de red puede leer las consultas y los préstamos. Para cifrarlo, Java ofrece
SSLServerSocketySSLSocket(paquetejavax.net.ssl), que se usan exactamente igual queServerSocketySocketpero cifran el tráfico con TLS; el trabajo real está en los certificados y los almacenes de claves. La seguridad de red se trata en profundidad en 12-07; aquí basta con saber que existe y que un servicio con datos sensibles no debe ir en claro.
- Probarlo con
telnet y con nc
telnet y con ncTerminal 1 — arranca el servidor:
javac -d clases $(find src -name "*.java")
java -cp clases com.nexussoftware.bibliotech.presentacion.ServidorBiblioTechAppTerminal 2 — conéctate con telnet y teclea las peticiones:
Sesión real completa (lo que tecleas va marcado):
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
200 BIBLIOTECH BTCP/1
CONSULTA 978-0000000001 <-- TECLEAS
200 OK 978-0000000001|Java Efectivo|LIBRO|true
CONSULTA 999 <-- TECLEAS
404 NO ENCONTRADO 999
CONSULTA ../../etc/passwd <-- TECLEAS
400 PETICION INVALIDA isbn no valido
LISTA <-- TECLEAS
201 LISTA 3
978-0000000001|Java Efectivo|LIBRO|true
978-0000000002|Patrones de Diseno|LIBRO|false
978-0000000003|Refactorizacion|LIBRO|true
.
PRESTAR 978-0000000003 Nuria Vidal <-- TECLEAS
200 OK prestamo registrado
PRESTAR 978-0000000003 Diego Alonso <-- TECLEAS
409 NO DISPONIBLE 978-0000000003
PIDEME UN CAFE <-- TECLEAS
400 PETICION INVALIDA comando desconocido: PIDEME
SALIR <-- TECLEAS
221 ADIOS
Connection closed by foreign host.Terminal 3 — a la vez que la 2, con nc:
200 BIBLIOTECH BTCP/1
201 LISTA 3
978-0000000001|Java Efectivo|LIBRO|true
978-0000000002|Patrones de Diseno|LIBRO|false
978-0000000003|Refactorizacion|LIBRO|true
.
221 ADIOSY el log del servidor con las tres conexiones simultáneas:
INFO: [/127.0.0.1:52440] conectado (1 activas)
INFO: [/127.0.0.1:52441] conectado (2 activas)
INFO: [/127.0.0.1:52441] prestamo de 978-0000000003 a Nuria Vidal
INFO: [/127.0.0.1:52442] conectado (3 activas)
INFO: [/127.0.0.1:52442] cerro la conexion
INFO: [/127.0.0.1:52442] desconectado (2 activas)
INFO: [/127.0.0.1:52441] desconectado (1 activas)
INFO: [/127.0.0.1:52440] desconectado (0 activas)Compara este log con el del servidor secuencial. Allí las conexiones se procesaban una detrás de otra; aquí se solapan. Esa es toda la diferencia, y viene de una línea: pool.execute(...) en lugar de atender en el propio hilo aceptador.
Pruebas que conviene hacer
- Conecta con
telnety no escribas nada durante 60 segundos. El servidor te expulsa:[cliente] expulsado por inactividad (60000 ms). SinsetSoTimeout, esetelnetretendría un hilo del pool para siempre. - Prueba el cliente de 09-02 contra este servidor. Ahora que existen los dos extremos,
PruebaClienteCatalogodebería funcionar de principio a fin. Es la primera vez que el código de las dos lecciones se encuentra. - Satura el servidor. Con
hilos=2ycola=2en la configuración, abre cincotelneta la vez: los últimos reciben503 SERVIDOR SATURADOy se cierran. Rechazar rápido y con explicación es mucho mejor que aceptar y no responder. - Envía una línea gigante:
python3 -c "print('A'*100000)" | nc localhost 9090. El servidor corta la conexión y registra el intento, en lugar de acumular cien mil caracteres en memoria. - Para el servidor con Ctrl+C mientras hay clientes conectados. El shutdown hook se dispara, el
acceptse desbloquea, y el log muestra el apagado en dos fases y las estadísticas finales.
- Apagado ordenado con shutdown hook
Un servidor no se para con System.exit() ni matando el proceso. Un servidor real recibe una señal —SIGTERM de systemd, de Docker o de Kubernetes; SIGINT de un Ctrl+C— y debe reaccionar cerrando bien.
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
LOG.info("Senal de apagado recibida");
servidor.close();
}, "bibliotech-apagado"));Un shutdown hook es un hilo que la JVM arranca cuando va a terminar. Reglas que hay que conocer:
| Regla | Detalle |
|---|---|
Se ejecuta con SIGTERM y SIGINT |
Ctrl+C, kill, docker stop, systemctl stop |
No se ejecuta con SIGKILL (kill -9) |
Nada puede interceptar kill -9. Por eso el estado importante se persiste, no se confía al hook |
| Debe ser rápido | El sistema suele dar un plazo (10 s en Docker por defecto) antes de pasar a SIGKILL |
No debe llamar a System.exit() |
Provoca un interbloqueo: la JVM ya está saliendo |
| Varios hooks se ejecutan en paralelo | Sin orden garantizado entre ellos |
| Las excepciones que lance se ignoran | Regístralas tú, o desaparecen |
En BiblioTech, el hook llama a ApagadoOrdenado —la clase de infraestructura del módulo 8— que ahora incorpora también el servidor:
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
LOG.info("Apagando BiblioTech...");
servidor.close(); // 1. dejar de aceptar y cerrar conversaciones
ApagadoOrdenado.detenerServicios(); // 2. pools de avisos, mantenimiento, reservas
AlmacenPrestamos.volcar(); // 3. persistir lo pendiente (modulo 7)
LOG.info("BiblioTech detenido correctamente");
}, "bibliotech-apagado"));El orden es deliberado y no es intercambiable: primero se deja de aceptar trabajo nuevo, después se termina el trabajo en curso, y al final se persiste. Al revés se persistiría un estado que todavía está cambiando.
- Límites del modelo y qué viene después
El modelo que has construido —un hilo de plataforma por conexión, con pool acotado— es sólido, sencillo de entender y correcto para la inmensa mayoría de servicios internos. Pero tiene un techo, y conviene saber dónde está.
El problema C10K
Con un pool de 16 hilos puedes atender 16 conversaciones simultáneas. Si las conexiones son largas y en su mayoría inactivas —piensa en un chat, o en notificaciones, donde cada cliente mantiene la conexión abierta durante horas y habla dos veces— el modelo se rompe: 10.000 clientes ociosos requerirían 10.000 hilos, es decir, varios gigabytes solo en pilas.
La respuesta clásica: NIO no bloqueante
Java tiene desde la versión 1.4 una API de E/S no bloqueante en java.nio.channels:
// E/S no bloqueante: UN hilo atiende miles de conexiones.
Selector selector = Selector.open();
ServerSocketChannel canal = ServerSocketChannel.open();
canal.bind(new InetSocketAddress(9090));
canal.configureBlocking(false); // clave: no bloqueante
canal.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // bloquea hasta que ALGUN canal tiene actividad
for (SelectionKey clave : selector.selectedKeys()) {
if (clave.isAcceptable()) { /* aceptar */ }
if (clave.isReadable()) { /* leer lo que haya, sin bloquear */ }
}
}Un Selector permite que un solo hilo vigile miles de canales y solo trabaje sobre los que tienen datos. Es lo que usan Netty, Tomcat NIO y prácticamente todos los servidores de alto rendimiento en Java.
El precio es la complejidad: el código se vuelve una máquina de estados, cada conexión necesita su propio buffer parcial porque las lecturas devuelven "lo que haya", y depurarlo es notablemente más difícil. No lo uses salvo que hayas medido que lo necesitas.
La respuesta moderna: hilos virtuales
Java 21 cambió el cálculo por completo. Los hilos virtuales son hilos gestionados por la JVM, no por el sistema operativo, que cuestan unos pocos cientos de bytes en lugar de un megabyte y se pueden crear por millones. Cuando uno se bloquea en E/S, la JVM lo desmonta del hilo del sistema y coloca otro.
// Java 21+: un hilo VIRTUAL por conexion. Sin pool, sin limite practico.
try (ExecutorService ejecutor = Executors.newVirtualThreadPerTaskExecutor()) {
while (ejecutando) {
Socket conexion = servidor.accept();
ejecutor.submit(() -> atenderConexion(conexion));
}
}Ese código es el mismo modelo mental de un hilo por conexión, con el código bloqueante y legible de siempre, pero escalando a cientos de miles de conexiones. Es la respuesta de Java al problema C10K sin pagar el precio de NIO.
Los hilos virtuales se tratan en 10-06. Por ahora quédate con la idea: el modelo que has aprendido no queda obsoleto, se vuelve aún más adecuado, porque el argumento en contra de "un hilo por conexión" era el coste del hilo, y ese coste ha desaparecido.
| Modelo | Conexiones que soporta | Complejidad | Cuándo |
|---|---|---|---|
| Un hilo por conexión, sin pool | Cientos | Baja | Nunca en producción: sin límite |
| Pool acotado de hilos de plataforma | Cientos | Baja | Servicios internos. Lo que has hecho |
NIO con Selector |
Cientos de miles | Alta | Alto rendimiento, si se ha medido |
| Hilos virtuales (Java 21+) | Cientos de miles | Baja | Lo nuevo por defecto (10-06) |
Errores Comunes y Consejos
Atender la conexión en el hilo del accept. Es el servidor secuencial: un cliente lento bloquea a todos, y uno malicioso tumba el servicio conectando y callándose. El hilo aceptador solo acepta y delega.
Usar Executors.newFixedThreadPool() sin pensar. Lleva una cola sin límite: bajo carga, cambias un fallo por otro. Usa ThreadPoolExecutor con LinkedBlockingQueue acotada y política de rechazo explícita.
Esperar que Thread.interrupt() desbloquee un accept(). No lo hace. Solo cerrar el ServerSocket lo desbloquea. Y lo mismo vale para socket.read(): por eso setSoTimeout no es opcional en un servidor.
Llamar a setReuseAddress después del bind. No tiene efecto. Hay que crear el ServerSocket sin argumentos, configurarlo y atarlo después.
No poner setSoTimeout en las conexiones aceptadas. Cada cliente que conecta y se calla se queda con un hilo del pool. Con un pool de 16, dieciséis clientes ociosos —o dieciséis telnet olvidados— dejan el servidor sin capacidad, y en el log no aparece ningún error.
Usar readLine() sin límite de longitud en un servidor expuesto. Cien megabytes sin un salto de línea agotan la memoria. Lee acotado.
Compartir el PrintWriter o el BufferedReader entre conexiones. Cada conversación tiene los suyos, siempre variables locales del método que la atiende. Un campo de instancia del servidor produce respuestas cruzadas entre clientes, y es un bug memorable de diagnosticar.
Encadenar dos operaciones seguras creyendo que el resultado es seguro. if (disponible) prestar() tiene una carrera aunque las dos llamadas sean atómicas por separado. La operación compuesta debe ser un método del servicio.
Dejar que una excepción mate al hilo del pool. Un catch (RuntimeException) alrededor de la atención de cada conexión es obligatorio. Si no, un fallo con un cliente concreto se lleva por delante un hilo del pool y, con suficientes casos, el servidor se queda sin hilos.
Devolver o registrar sin sanear lo que llegó por la red. Un salto de línea inyecta una respuesta falsa en el protocolo; una secuencia de escape ANSI manipula el terminal de quien lea el log.
Olvidar el flush() en las respuestas del servidor. Es el bug número uno de 09-02, ahora desde el otro lado: el cliente espera una respuesta que sigue en tu buffer.
No cerrar el socket del cliente al terminar. Cada socket es un descriptor de fichero, y hay un límite por proceso (a menudo 1024 por defecto). Un servidor que fuga sockets acaba con Too many open files, error que aparece horas después de arrancar y desconcierta a todo el mundo. El try (conexion) lo resuelve.
Consejo de diagnóstico. ss -tnp | grep 9090 te muestra en vivo todas las conexiones con su estado. Muchas en CLOSE_WAIT significa que tú no estás cerrando tus sockets; muchas en TIME_WAIT es normal tras muchas conexiones cortas. Es el equivalente de red a mirar la memoria: te dice si estás fugando.
Ejercicios
Ejercicio 1: Servidor de estadísticas de BiblioTech
Añade al servidor un segundo puerto (9092) que sirva un informe de estado en texto plano, pensado para que el equipo de sistemas de Nexus Software lo consulte con nc o desde un panel.
Requisitos:
- Un
ServerSocketpropio en 9092, con su propio hilo aceptador, compartiendo el pool con el servidor principal. - Al conectar, sin necesidad de enviar nada, el servidor escribe el informe y cierra: conexiones activas, totales, rechazadas, peticiones atendidas, hilos activos del pool, tareas en cola, tiempo en marcha en segundos y memoria usada.
- Escucha solo en
127.0.0.1: las métricas no deben ser accesibles desde la red. Explica por qué en un comentario. - Debe funcionar con
nc localhost 9092y concurl -s telnet://localhost:9092.
Ejercicio 2: Limitador de conexiones por cliente
Añade al ServidorCatalogo un límite de conexiones simultáneas por dirección IP, para que un solo equipo no pueda ocupar todo el pool.
Requisitos:
- Un
ConcurrentHashMap<String, AtomicInteger>que cuente conexiones activas por IP. - Máximo configurable (por defecto 3) por IP;
127.0.0.1exenta, para no bloquearte a ti mismo durante las pruebas. - Al superarse, responder
429 DEMASIADAS CONEXIONESy cerrar, incrementando un contador de rechazos por límite. - El contador debe decrementarse siempre al terminar la conexión, incluso si hubo excepción. Piensa bien dónde va ese decremento.
- Las entradas del mapa deben eliminarse cuando llegan a cero, o el mapa crece indefinidamente. Usa una operación atómica para el par "decrementar y quizá eliminar", y explica por qué hacerlo en dos pasos tendría una carrera.
Pruébalo abriendo cuatro telnet desde la misma máquina con la exención desactivada.
Ejercicio 3: Prueba de carga del servidor
Escribe una clase PruebaCarga que mida el comportamiento del ServidorCatalogo bajo carga usando el ClienteCatalogo de 09-02.
Requisitos:
- Parámetros: número de clientes concurrentes, peticiones por cliente y host/puerto.
- Cada cliente se ejecuta como una tarea en un
ExecutorService, conecta, hace N consultas de ISBN aleatorios de una lista, y cierra. - Recoger con estructuras atómicas: peticiones correctas, errores, y latencias para calcular mínima, media, mediana, percentil 95 y máxima.
- Usar
CountDownLatchpara que todos los clientes arranquen a la vez (así se mide carga real, no una rampa) y para esperar a que terminen. - Informe final con peticiones por segundo y la tabla de latencias.
- Apagado en dos fases del pool de la prueba.
Ejecuta con 4, 16 y 64 clientes contra un servidor de 16 hilos y comenta qué observas.
Soluciones
Solución 1
package com.nexussoftware.bibliotech.red;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Servidor de estadisticas de BiblioTech.
*
* SEGURIDAD: escucha SOLO en 127.0.0.1. Las metricas de un servicio revelan
* informacion util para un atacante (carga, saturacion, memoria, ventanas de
* baja actividad) y suelen ser el primer sitio donde se filtra algo sin querer.
* Exponerlas a la red seria un fallo de diseno; si hay que consultarlas desde
* fuera, se hace por un tunel SSH o tras un proxy autenticado (12-07).
*/
public class ServidorEstadisticas implements AutoCloseable {
private static final Logger LOG = Logger.getLogger(ServidorEstadisticas.class.getName());
private final int puerto;
private final ServidorCatalogo principal;
private final ThreadPoolExecutor poolPrincipal;
private final ExecutorService pool;
private final long instanteArranque = System.currentTimeMillis();
private ServerSocket servidor;
private volatile boolean ejecutando = false;
public ServidorEstadisticas(int puerto, ServidorCatalogo principal,
ThreadPoolExecutor poolPrincipal, ExecutorService pool) {
this.puerto = puerto;
this.principal = principal;
this.poolPrincipal = poolPrincipal;
this.pool = pool; // se comparte el pool: no hacen falta hilos propios
}
public void arrancar() throws IOException {
servidor = new ServerSocket();
servidor.setReuseAddress(true);
// El tercer argumento del bind ATA la escucha al bucle invertido:
// ni siquiera un fallo de cortafuegos expondria este puerto.
servidor.bind(new InetSocketAddress(
InetAddress.getLoopbackAddress(), puerto), 10);
ejecutando = true;
// Hilo aceptador propio, porque accept() bloquea.
Thread aceptador = new Thread(this::ejecutar, "bibliotech-stats-aceptador");
aceptador.setDaemon(true); // este si puede ser daemon: no tiene estado
aceptador.start();
LOG.info(() -> "Servidor de estadisticas en 127.0.0.1:" + puerto);
}
private void ejecutar() {
while (ejecutando) {
try {
Socket conexion = servidor.accept();
conexion.setSoTimeout(5_000);
// Se delega al pool compartido: no atendemos en el aceptador.
pool.execute(() -> servirInforme(conexion));
} catch (SocketException e) {
if (!ejecutando) {
LOG.info("Servidor de estadisticas detenido");
return;
}
LOG.log(Level.SEVERE, "Fallo en accept() de estadisticas", e);
return;
} catch (IOException e) {
LOG.log(Level.WARNING, "Error aceptando conexion de estadisticas", e);
}
}
}
/**
* Este servicio no tiene protocolo: al conectar, escribe y cierra.
* Es lo que permite consultarlo con un simple "nc localhost 9092".
*/
private void servirInforme(Socket conexion) {
try (conexion) {
BufferedWriter escritor = new BufferedWriter(
new OutputStreamWriter(conexion.getOutputStream(),
StandardCharsets.UTF_8));
escritor.write(construirInforme());
escritor.flush(); // el flush de siempre
} catch (IOException e) {
LOG.fine("Fallo sirviendo el informe: " + e.getMessage());
}
}
private String construirInforme() {
Runtime rt = Runtime.getRuntime();
long usada = (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024);
long maxima = rt.maxMemory() / (1024 * 1024);
long segundos = (System.currentTimeMillis() - instanteArranque) / 1000;
StringBuilder sb = new StringBuilder();
sb.append("=== BIBLIOTECH - ESTADO DEL SERVIDOR ===\n");
sb.append(String.format("%-28s %s%n", "En marcha desde hace",
formatearDuracion(segundos)));
sb.append('\n');
sb.append("--- CONEXIONES ---\n");
sb.append(String.format("%-28s %d%n", "Activas", principal.conexionesActivas()));
sb.append(String.format("%-28s %d%n", "Totales", principal.conexionesTotales()));
sb.append(String.format("%-28s %d%n", "Peticiones atendidas",
principal.peticionesAtendidas()));
sb.append('\n');
sb.append("--- POOL DE HILOS ---\n");
sb.append(String.format("%-28s %d%n", "Hilos activos",
poolPrincipal.getActiveCount()));
sb.append(String.format("%-28s %d%n", "Tamano del pool",
poolPrincipal.getPoolSize()));
sb.append(String.format("%-28s %d%n", "Tareas en cola",
poolPrincipal.getQueue().size()));
sb.append(String.format("%-28s %d%n", "Tareas completadas",
poolPrincipal.getCompletedTaskCount()));
sb.append('\n');
sb.append("--- MEMORIA ---\n");
sb.append(String.format("%-28s %d MB de %d MB (%.1f%%)%n",
"Usada", usada, maxima, 100.0 * usada / maxima));
sb.append(String.format("%-28s %d%n", "Nucleos disponibles",
rt.availableProcessors()));
return sb.toString();
}
private String formatearDuracion(long segundos) {
// Sin java.time (eso es 10-05): aritmetica simple sobre enteros.
long h = segundos / 3600;
long m = (segundos % 3600) / 60;
long s = segundos % 60;
return String.format("%dh %02dm %02ds", h, m, s);
}
@Override
public void close() {
ejecutando = false;
try {
if (servidor != null) {
servidor.close(); // desbloquea el accept()
}
} catch (IOException ignorada) {
// Cerrando ya no hay nada que salvar.
}
}
}Prueba:
=== BIBLIOTECH - ESTADO DEL SERVIDOR ===
En marcha desde hace 0h 04m 17s
--- CONEXIONES ---
Activas 2
Totales 47
Peticiones atendidas 183
--- POOL DE HILOS ---
Hilos activos 2
Tamano del pool 16
Tareas en cola 0
Tareas completadas 45
--- MEMORIA ---
Usada 28 MB de 4096 MB (0,7%)
Nucleos disponibles 8Comentarios. Tres decisiones de diseño. La primera y más importante: atar la escucha a InetAddress.getLoopbackAddress() en el bind. No es lo mismo que confiar en un cortafuegos: el socket ni siquiera existe en las demás interfaces, así que un error de configuración de red no puede exponerlo. La segunda: se comparte el pool del servidor principal en lugar de crear otro; el servicio de estadísticas atiende conexiones instantáneas y esporádicas, y darle hilos propios sería desperdiciar memoria. La tercera: este servicio no tiene protocolo, escribe y cierra, y eso es precisamente lo que permite consultarlo con nc sin nada más. Fíjate además en que el hilo aceptador de estadísticas sí es daemon, al contrario que los del pool principal: no tiene estado que perder, así que no hay problema en que la JVM lo mate al salir.
Solución 2
package com.nexussoftware.bibliotech.red;
import java.net.Socket;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.logging.Logger;
/**
* Limitador de conexiones simultaneas por direccion IP.
*
* Evita que un solo equipo (por error o a proposito) ocupe todo el pool
* de hilos del servidor y deje sin servicio a los demas puestos de trabajo.
*/
public class LimitadorPorIp {
private static final Logger LOG = Logger.getLogger(LimitadorPorIp.class.getName());
private final int maximoPorIp;
private final boolean eximirBucleInvertido;
/** Cuenta conexiones activas por IP. Concurrente: lo tocan todos los hilos. */
private final ConcurrentHashMap<String, AtomicInteger> porIp = new ConcurrentHashMap<>();
private final AtomicLong rechazadasPorLimite = new AtomicLong();
public LimitadorPorIp(int maximoPorIp, boolean eximirBucleInvertido) {
this.maximoPorIp = maximoPorIp;
this.eximirBucleInvertido = eximirBucleInvertido;
}
/**
* Intenta reservar una plaza para esta IP.
* Devuelve true si se admite; false si se ha alcanzado el limite.
*/
public boolean admitir(Socket conexion) {
String ip = ipDe(conexion);
if (eximirBucleInvertido && esBucleInvertido(ip)) {
// Sin la exencion, probar el servidor con varios telnet desde
// la propia maquina seria imposible.
return true;
}
// computeIfAbsent es atomico: si dos hilos llegan a la vez con la
// misma IP, solo uno crea el AtomicInteger y ambos usan el mismo.
AtomicInteger contador = porIp.computeIfAbsent(ip, clave -> new AtomicInteger());
// Bucle de comparar-e-intercambiar: la forma atomica de decir
// "incrementa SOLO SI el valor actual es menor que el maximo".
// Un "if (get() < max) incrementAndGet()" tendria una carrera:
// dos hilos podrian pasar el if a la vez y superar el limite.
while (true) {
int actual = contador.get();
if (actual >= maximoPorIp) {
rechazadasPorLimite.incrementAndGet();
LOG.warning("Limite de " + maximoPorIp
+ " conexiones alcanzado para " + ip);
return false;
}
if (contador.compareAndSet(actual, actual + 1)) {
return true; // reserva conseguida
}
// compareAndSet fallo: otro hilo cambio el valor entremedias.
// Se reintenta con el valor nuevo. Esto es 08-06.
}
}
/**
* Libera la plaza. DEBE llamarse siempre, desde un finally.
*/
public void liberar(Socket conexion) {
String ip = ipDe(conexion);
if (eximirBucleInvertido && esBucleInvertido(ip)) {
return; // no se reservo, no se libera
}
// computeIfPresent hace ATOMICAMENTE el par "decrementar y, si
// llega a cero, eliminar la entrada". Devolver null desde la
// funcion elimina la clave del mapa.
//
// Hacerlo en dos pasos tendria una carrera clasica:
// if (contador.decrementAndGet() == 0) porIp.remove(ip);
// Entre el decremento y el remove, otro hilo puede incrementar
// ese mismo contador a 1 (una conexion nueva de la misma IP), y
// el remove eliminaria un contador que ya vale 1. La conexion
// nueva quedaria contabilizada en un objeto huerfano y el limite
// dejaria de aplicarse a esa IP.
porIp.computeIfPresent(ip, (clave, contador) -> {
int quedan = contador.decrementAndGet();
return quedan <= 0 ? null : contador; // null elimina la entrada
});
}
private String ipDe(Socket conexion) {
return conexion.getInetAddress().getHostAddress();
}
private boolean esBucleInvertido(String ip) {
return ip.equals("127.0.0.1") || ip.equals("0:0:0:0:0:0:0:1") || ip.equals("::1");
}
public long rechazadasPorLimite() {
return rechazadasPorLimite.get();
}
public int ipsConectadas() {
return porIp.size();
}
}Integración en el bucle de aceptación de ServidorCatalogo:
// En ejecutar(), tras aceptar:
Socket aceptada = servidor.accept();
aceptada.setSoTimeout(limiteInactividadMs);
aceptada.setTcpNoDelay(true);
if (!limitador.admitir(aceptada)) {
rechazar(aceptada, "429 DEMASIADAS CONEXIONES");
continue; // NO se llego a delegar al pool: no hay nada que liberar
}
try {
pool.execute(() -> atenderConexion(aceptada));
} catch (RejectedExecutionException e) {
// OJO: la plaza YA estaba reservada. Si no se libera aqui,
// se fuga una plaza en cada rechazo del pool y la IP acaba
// bloqueada para siempre.
limitador.liberar(aceptada);
rechazar(aceptada, "503 SERVIDOR SATURADO");
}Y en atenderConexion, el decremento va en el finally que ya existía:
} finally {
limitador.liberar(conexion); // SIEMPRE, pase lo que pase
int quedan = conexionesActivas.decrementAndGet();
LOG.info(() -> "[" + cliente + "] desconectado (" + quedan + " activas)");
}Comentarios. Este ejercicio concentra tres lecciones de concurrencia del módulo 8 aplicadas a un problema real.
El bucle de comparar-e-intercambiar es la parte que más se falla. El código intuitivo, if (contador.get() < maximo) contador.incrementAndGet(), tiene una carrera evidente: con el límite en 3 y el contador en 2, dos hilos pueden leer 2, ambos pasar el if y ambos incrementar, dejándolo en 4. El bucle con compareAndSet incrementa solo si el valor no ha cambiado desde que lo leímos, y reintenta si cambió. Es exactamente el patrón de 08-06.
El computeIfPresent para decrementar y eliminar resuelve una carrera más sutil, explicada en el comentario del código: el par "decrementar" y "eliminar si es cero" debe ser una sola operación atómica, o una conexión nueva de la misma IP puede colarse entre las dos y quedar contabilizada en un contador que se acaba de sacar del mapa. Y eliminar la entrada no es un detalle estético: sin ello, porIp acumula una entrada por cada IP que se haya conectado alguna vez, y eso es una fuga de memoria lenta pero segura.
El liberar en el catch de RejectedExecutionException es el fallo que más cuesta encontrar. La plaza se reserva antes de delegar al pool; si el pool rechaza, esa plaza queda reservada para siempre porque nunca se ejecutará el finally de atenderConexion. Bastan tres saturaciones para que un puesto de trabajo quede bloqueado permanentemente, y el síntoma —"desde el ordenador de Marta no se puede entrar, pero desde el mío sí"— es de los que llevan un día entero de depuración.
Solución 3
package com.nexussoftware.bibliotech.red;
import com.nexussoftware.bibliotech.excepcion.BiblioTechException;
import java.util.Arrays;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
/**
* Prueba de carga del ServidorCatalogo usando el ClienteCatalogo de 09-02.
* Mide latencias, tasa de error y peticiones por segundo.
*/
public class PruebaCarga {
private static final String[] ISBNS = {
"978-0000000001",
"978-0000000002",
"978-0000000003",
"000-0000000000" // inexistente a proposito: debe dar 404, no error
};
private final String host;
private final int puerto;
private final AtomicInteger correctas = new AtomicInteger();
private final AtomicInteger errores = new AtomicInteger();
private final AtomicInteger conexionesFallidas = new AtomicInteger();
private final AtomicLong indiceLatencias = new AtomicLong();
/** Latencias en microsegundos. Array preasignado: sin sincronizacion al escribir. */
private long[] latencias;
public PruebaCarga(String host, int puerto) {
this.host = host;
this.puerto = puerto;
}
public void ejecutar(int clientes, int peticionesPorCliente) throws InterruptedException {
latencias = new long[clientes * peticionesPorCliente];
// Pool para los clientes de la prueba, con hilos nombrados.
ExecutorService pool = Executors.newFixedThreadPool(clientes, r -> {
Thread h = new Thread(r);
h.setName("carga-" + h.getId());
return h;
});
// Dos latches: uno para que TODOS arranquen a la vez (carga real,
// no una rampa), y otro para esperar a que TODOS terminen (08-05).
CountDownLatch salida = new CountDownLatch(1);
CountDownLatch llegada = new CountDownLatch(clientes);
for (int i = 0; i < clientes; i++) {
final int id = i;
pool.execute(() -> {
try {
salida.await(); // todos esperan aqui al pistoletazo
trabajar(id, peticionesPorCliente);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
llegada.countDown();
}
});
}
System.out.printf("Lanzando %d clientes x %d peticiones = %d peticiones%n%n",
clientes, peticionesPorCliente, clientes * peticionesPorCliente);
long t0 = System.nanoTime();
salida.countDown(); // ¡ya!
llegada.await(); // esperamos a todos
long duracionNs = System.nanoTime() - t0;
// Apagado en dos fases del pool de la prueba (08-05).
pool.shutdown();
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
pool.shutdownNow();
}
informe(duracionNs);
}
private void trabajar(int id, int peticiones) {
// Cada cliente abre su propia conexion: ClienteCatalogo NO es
// seguro para varios hilos, y ademas asi medimos conexiones reales.
try (ClienteCatalogo cliente = new ClienteCatalogo(host, puerto)) {
cliente.conectar();
for (int i = 0; i < peticiones; i++) {
String isbn = ISBNS[(id + i) % ISBNS.length];
long t0 = System.nanoTime();
try {
cliente.consultar(isbn); // null (404) tambien es exito
long us = (System.nanoTime() - t0) / 1000;
// Cada hilo escribe en su propia posicion del array:
// sin colisiones, sin sincronizacion. El indice se
// reparte con un contador atomico.
int pos = (int) indiceLatencias.getAndIncrement();
if (pos < latencias.length) {
latencias[pos] = us;
}
correctas.incrementAndGet();
} catch (BiblioTechException e) {
errores.incrementAndGet();
}
}
} catch (BiblioTechException e) {
// No se pudo ni conectar: se cuenta aparte, porque significa
// otra cosa (saturacion) que un error en una peticion.
conexionesFallidas.incrementAndGet();
}
}
private void informe(long duracionNs) {
int hechas = (int) Math.min(indiceLatencias.get(), latencias.length);
if (hechas == 0) {
System.out.println("No se completo ninguna peticion.");
System.out.println("Conexiones fallidas: " + conexionesFallidas.get());
return;
}
long[] ordenadas = Arrays.copyOf(latencias, hechas);
Arrays.sort(ordenadas); // 05-09: ordenacion para los percentiles
long suma = 0;
for (long v : ordenadas) {
suma += v;
}
double segundos = duracionNs / 1_000_000_000.0;
double porSegundo = correctas.get() / segundos;
System.out.println("=== RESULTADO DE LA PRUEBA DE CARGA ===");
System.out.printf("%-26s %.2f s%n", "Duracion total", segundos);
System.out.printf("%-26s %d%n", "Peticiones correctas", correctas.get());
System.out.printf("%-26s %d%n", "Peticiones con error", errores.get());
System.out.printf("%-26s %d%n", "Conexiones fallidas", conexionesFallidas.get());
System.out.printf("%-26s %.0f%n", "Peticiones por segundo", porSegundo);
System.out.println();
System.out.println("--- LATENCIAS (ms) ---");
System.out.printf("%-26s %.3f%n", "Minima", ordenadas[0] / 1000.0);
System.out.printf("%-26s %.3f%n", "Media", (suma / (double) hechas) / 1000.0);
System.out.printf("%-26s %.3f%n", "Mediana (p50)", percentil(ordenadas, 50) / 1000.0);
System.out.printf("%-26s %.3f%n", "p95", percentil(ordenadas, 95) / 1000.0);
System.out.printf("%-26s %.3f%n", "p99", percentil(ordenadas, 99) / 1000.0);
System.out.printf("%-26s %.3f%n", "Maxima",
ordenadas[ordenadas.length - 1] / 1000.0);
}
private long percentil(long[] ordenadas, int p) {
// Indice del percentil sobre el array ya ordenado.
int indice = (int) Math.ceil(p / 100.0 * ordenadas.length) - 1;
return ordenadas[Math.max(0, Math.min(indice, ordenadas.length - 1))];
}
public static void main(String[] args) throws InterruptedException {
String host = args.length > 0 ? args[0] : "localhost";
int puerto = args.length > 1 ? Integer.parseInt(args[1]) : 9090;
for (int clientes : new int[]{4, 16, 64}) {
System.out.println("############ " + clientes + " CLIENTES ############");
new PruebaCarga(host, puerto).ejecutar(clientes, 200);
System.out.println();
Thread.sleep(1000); // margen para que el servidor se recupere
}
}
}Salida contra un servidor de 16 hilos y cola de 100:
############ 4 CLIENTES ############
Lanzando 4 clientes x 200 peticiones = 800 peticiones
=== RESULTADO DE LA PRUEBA DE CARGA ===
Duracion total 0,42 s
Peticiones correctas 800
Peticiones con error 0
Conexiones fallidas 0
Peticiones por segundo 1897
--- LATENCIAS (ms) ---
Minima 0,118
Media 0,204
Mediana (p50) 0,171
p95 0,392
p99 0,884
Maxima 4,113
############ 16 CLIENTES ############
Peticiones por segundo 6104
Mediana (p50) 0,224
p95 0,668
p99 1,942
Maxima 11,208
############ 64 CLIENTES ############
Conexiones fallidas 0
Peticiones por segundo 6438
Mediana (p50) 1,102
p95 4,873
p99 12,441
Maxima 58,306Comentarios. Los números cuentan una historia muy clara, y es la misma que verás en cualquier servicio real.
De 4 a 16 clientes, el rendimiento se multiplica por 3,2 (1897 → 6104 peticiones/s) y la latencia mediana apenas se mueve. El servidor tenía capacidad de sobra y ahora la está usando: el pool de 16 hilos está aprovechado.
De 16 a 64 clientes, el rendimiento se estanca (6104 → 6438, un 5 % más) pero la latencia mediana se multiplica por cinco y el p99 por seis. Esto es el comportamiento canónico de un sistema saturado: pasado el punto de saturación, más carga no da más trabajo hecho, solo más cola. Los 64 clientes se reparten los mismos 16 hilos, así que cada uno espera su turno.
Fíjate en el p99 frente a la mediana. Con 64 clientes, la mitad de las peticiones tardan menos de 1,1 ms, pero una de cada cien tarda más de 12 ms, y la peor 58 ms. La media miente y la mediana no basta: en un servicio real, lo que perciben los usuarios como "va lento" es el p95 y el p99, no la media. Medir solo la media es el error clásico de las pruebas de rendimiento.
Y la máxima de 4,1 ms con solo 4 clientes es el calentamiento de la JVM: las primeras peticiones cargan clases y ejecutan código aún interpretado, antes de que el compilador JIT haga su trabajo. Cualquier prueba de rendimiento seria descarta un periodo inicial de calentamiento; si no, esos valores contaminan el máximo y el p99.
Conclusión
Has construido el otro extremo, y con él BiblioTech ha dejado de estar sola de verdad: hay un servidor en una máquina de Nexus Software al que Marta, Diego y Nuria se conectan a la vez desde sus puestos.
Sabes distinguir las dos clases que intervienen y por qué son distintas: ServerSocket espera, Socket conversa. Hay uno del primero por servicio y uno del segundo por cliente conectado, y en el ServerSocket no se lee ni se escribe: su único trabajo es fabricar conexiones. Conoces las tres decisiones de su creación —el puerto, el backlog que amortigua ráfagas pero no arregla un servidor lento, y la dirección de escucha, que es una decisión de seguridad: 0.0.0.0 expone a la red y 127.0.0.1 solo a la propia máquina—. Y sabes que el puerto 0 hace que el sistema te asigne uno libre, la técnica estándar en pruebas automatizadas.
Entiendes accept(): bloquea, devuelve un Socket ya conectado —el saludo de tres vías lo completó el sistema operativo antes, y por eso un cliente puede estar conectado y esperando en la cola aunque tu código esté ocupado—, admite setSoTimeout y, sobre todo, no responde a Thread.interrupt(). Cerrar el ServerSocket es lo único que lo desbloquea, y esa es la pieza que gobierna todo el apagado.
Has resuelto el BindException: Address already in use, que vas a encontrar seguro: si es otro proceso, ss -tlnp lo delata; si es TIME_WAIT, la solución es setReuseAddress(true) antes del bind, lo que obliga al patrón de tres pasos: crear sin atar, configurar, atar.
Has visto en directo, con tres terminales de telnet, por qué un servidor secuencial es inaceptable: el segundo cliente conecta pero no recibe ni el saludo hasta que el primero termina; y peor, un solo cliente que conecte y se calle deja el servicio caído. Has descartado también el "un hilo por conexión sin límite" por lo que realmente es: una vulnerabilidad de denegación de servicio, porque diez mil conexiones crean diez mil hilos y tumban la JVM.
Y has aplicado la solución del módulo 8 en el problema para el que se inventó: un ThreadPoolExecutor acotado, con ThreadFactory que da nombres legibles a los hilos para que los logs y los volcados sirvan de algo, cola acotada —porque newFixedThreadPool lleva una sin límite y eso solo cambia un OutOfMemoryError por otro— y política de rechazo explícita, con AbortPolicy y un 503 SERVIDOR SATURADO de cortesía, porque rechazar rápido y con explicación siempre es mejor que aceptar y no responder. El hilo aceptador solo acepta y delega; toda la conversación ocurre en el pool.
Dominas el apagado ordenado y sus tres piezas, ninguna prescindible: la bandera volatile que distingue el cierre intencionado de un fallo real —sin ella, cada apagado limpio deja un SEVERE en el log—, el cierre del ServerSocket que desbloquea el accept, y el apagado en dos fases del pool con shutdown, awaitTermination, shutdownNow y restauración de la bandera de interrupción. Con el detalle que lo ata todo: shutdownNow() tampoco desbloquea un socket.read(), y por eso el setSoTimeout de cada conexión no es un adorno defensivo sino el mecanismo que hace que el apagado funcione.
Has comprobado que el estado compartido ya estaba resuelto: CatalogoConcurrente con su ConcurrentHashMap, EstadisticasBiblioTech con sus LongAdder, RegistroPrestamosSeguro con su ReadWriteLock. No hubo que tocar una línea. Lo único que sigue exigiendo cuidado es lo de siempre: las operaciones compuestas no son atómicas por encadenar operaciones atómicas, y por eso prestar es un único método del servicio y no un if seguido de una llamada.
Y sabes que todo lo que llega por un socket es entrada no confiable, aunque venga de la red interna. Tu servidor lo trata como tal: lectura de línea acotada en lugar de un readLine() sin límite, tope de peticiones por conexión, setSoTimeout que expulsa a los inactivos, pool y cola acotados, validación por lista blanca —nunca lista negra—, saneado de todo lo que vuelve al cliente o al log para evitar la inyección de saltos de línea y de escapes de terminal, y un catch (RuntimeException) por conexión para que un fallo con un cliente no se lleve por delante un hilo del pool.
BiblioTech gana en esta lección ServidorCatalogo —el servidor BTCP/1 completo, multicliente, validado, medido y con apagado ordenado—, ServidorBiblioTechApp con su shutdown hook, y las clases de los ejercicios: ServidorEstadisticas atado al bucle invertido, LimitadorPorIp con su bucle de comparar-e-intercambiar, y PruebaCarga, que te ha enseñado con números el comportamiento de un sistema saturado: pasado el punto de saturación, más carga no da más trabajo hecho, solo más cola, y la media miente mientras el p99 dice la verdad.
Sabes además dónde está el techo de este modelo y qué hay más allá: NIO con Selector para el problema C10K, potente y notablemente más complejo; y los hilos virtuales de Java 21, que no invalidan lo que has aprendido sino que lo hacen aún más adecuado, porque el único argumento contra "un hilo por conexión" era el coste del hilo y ese coste ha desaparecido (10-06). Y sabes que BTCP/1 viaja en claro, que SSLServerSocket es la respuesta, y que la seguridad de red se trata a fondo en 12-07.
En la próxima lección, DatagramSocket y DatagramPacket, cambia el transporte. Vas a soltar todas las garantías de TCP: sin conexión, sin entrega asegurada, sin orden, sin control de flujo — y vas a descubrir por qué eso a veces es exactamente lo que quieres. Verás el DatagramPacket como un sobre con datos, longitud, dirección y puerto, y el DatagramSocket como un buzón; los dos errores clásicos que comete todo el mundo con ellos; qué son la MTU y la fragmentación, y por qué 512 bytes es lo prudente; cómo tolerar pérdidas, duplicados y desorden, y cómo construir fiabilidad a mano cuando hace falta; y la difusión y la multidifusión, que TCP simplemente no puede hacer. Con dos aplicaciones para BiblioTech que TCP no permite: un servicio de descubrimiento en el que los puestos de trabajo gritan «¿DÓNDE ESTÁ BIBLIOTECH?» a toda la red local y el servidor responde con su dirección —de modo que ya no habrá que configurar la IP en cada puesto— y un emisor de telemetría que envía el número de préstamos activos cada pocos segundos sin bloquear y sin importar si algún paquete se pierde por el camino.
Curso de Programación en Java
Módulo 1: Introducción a Java
- Introducción a Java
- Configuración del Entorno de Desarrollo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Operadores
- Entrada y Salida por Consola
- Tu Primer Programa Completo: BiblioTech
Módulo 2: Flujo de Control
- Sentencias Condicionales
- Bucles
- Sentencias Switch
- Break y Continue
- Depuración y Trazas de Ejecución
- Proyecto: Menú Interactivo de BiblioTech
Módulo 3: Programación Orientada a Objetos
- Introducción a la POO
- Clases y Objetos
- Métodos
- Constructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- La Clase Object: equals, hashCode y toString
Módulo 4: Programación Orientada a Objetos Avanzada
- Interfaces
- Clases Abstractas
- Clases Internas
- Clases Anónimas
- Expresiones Lambda
- Interfaces Funcionales y Referencias a Métodos
- Enumeraciones y Registros
Módulo 5: Estructuras de Datos y Colecciones
- Arreglos
- El Framework de Colecciones
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cola y Deque
- Pila
- Ordenación y Búsqueda en Colecciones
Módulo 6: Manejo de Excepciones
- Introducción a las Excepciones
- Bloque Try-Catch
- Throw y Throws
- Excepciones Personalizadas
- Bloque Finally
- Try-with-resources y AutoCloseable
- Estrategias de Manejo de Errores y Logging
Módulo 7: Entrada/Salida de Archivos
- Lectura de Archivos
- Escritura de Archivos
- Flujos de Archivos
- BufferedReader y BufferedWriter
- Serialización
- La API NIO.2: Path y Files
- Formatos de Intercambio: CSV y Properties
Módulo 8: Multihilo y Concurrencia
- Introducción al Multihilo
- Creación de Hilos
- Ciclo de Vida de un Hilo
- Sincronización
- Utilidades de Concurrencia
- Colecciones Concurrentes y Variables Atómicas
- Tareas Asíncronas con CompletableFuture
Módulo 9: Redes
- Introducción a las Redes
- Sockets
- ServerSocket
- DatagramSocket y DatagramPacket
- URL y HttpURLConnection
- El Cliente HTTP Moderno
Módulo 10: Temas Avanzados
- Genéricos
- Anotaciones
- Reflexión
- Características de Java 8: Streams y Optional
- Fechas y Horas con java.time
- Java 9 y Más Allá
- Memoria, Recolección de Basura y Rendimiento
Módulo 11: Frameworks y Librerías de Java
- Introducción a los Frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Pruebas Avanzadas con Mockito
- Librerías Esenciales del Ecosistema
