A la lliçó anterior vas escriure ClientCataleg, que parla BTCP/1 perfectament. El seu únic problema és que no té amb qui parlar: el servidor eres tu, teclejant respostes a mà en un nc -l 9090. Aquesta lliçó construeix l'altre extrem.
ServerSocket és la classe que permet a un programa esperar connexions en lloc d'iniciar-les. És una inversió completa del paper: el client sap on va i pren la iniciativa; el servidor no sap qui vindrà ni quan, es queda escoltant en un port conegut i reacciona.
I aquí és on el mòdul 8 deixa de ser teoria. Un servidor que atén un client cada vegada és inútil: mentre la Marta consulta el catàleg, en Diego espera. La solució —un pool acotat de fils, amb noms per als logs, aturada en dues fases i estat compartit protegit per estructures concurrents— és exactament el que vas aprendre al mòdul 8, aplicat al problema per al qual es va dissenyar. El CatalegConcurrent amb el seu ConcurrentHashMap feia dos mòduls que esperava aquest moment.
En acabar, el servidor de catàleg de BiblioTech estarà funcionant: acceptarà la Marta, en Diego i la Nuria alhora, validarà tot el que arribi per la xarxa, respondrà amb codis, expulsarà els clients inactius, limitarà les connexions simultànies i s'apagarà ordenadament. I hi podràs parlar des de telnet.
Contingut
ServerSocket: escoltar en un port- El
bind, elbacklogi l'adreça d'escolta accept(): el mètode que lliura connexionsBindException: Address already in useisetReuseAddress- El servidor seqüencial i la demostració del seu límit
- El servidor concurrent: un fil per connexió
- La solució correcta:
ExecutorService - Tancar el
ServerSocketper desbloquejar l'accept - Estat compartit entre connexions
- BiblioTech: el servidor de catàleg complet
- Validació de l'entrada: no confiar mai en la xarxa
- Provar-ho amb
telneti ambnc - Aturada ordenada amb shutdown hook
- Límits del model i què ve després
- Errors Comuns i Consells
- Exercicis
ServerSocket: escoltar en un port
ServerSocket: escoltar en un portUn ServerSocket no és un socket de conversa. No s'hi llegeix ni s'hi escriu. La seva única feina és esperar connexions entrants i, per cadascuna, fabricar un Socket normal —dels de 09-02— pel qual sí que es conversa.
import java.net.ServerSocket;
import java.net.Socket;
// El constructor fa el "bind": reserva el port 9090 per a aquest proces.
try (ServerSocket servidor = new ServerSocket(9090)) {
System.out.println("Escoltant al port " + servidor.getLocalPort());
// accept() BLOQUEJA fins que arriba una connexio.
// Quan torna, retorna un Socket JA CONNECTAT al client.
Socket connexio = servidor.accept();
System.out.println("Client connectat des de " + connexio.getRemoteSocketAddress());
// A partir d'aqui, 'connexio' es exactament el Socket de 09-02:
// getInputStream(), getOutputStream(), setSoTimeout(), close()...
}La distinció entre els dos objectes és la clau de tota la lliçó:
ServerSocket |
Socket (el que retorna accept) |
|
|---|---|---|
| Per a què serveix | Esperar connexions | Conversar amb un client concret |
| Quants n'hi ha | Un per servei | Un per client connectat |
| Port | El conegut (9090) | El mateix 9090 al costat local; el client té el seu efímer |
| Fluxos | No en té | getInputStream() / getOutputStream() |
| Es tanca | En aturar el servei | En acabar cada conversa |
sequenceDiagram
participant SO as Sistema operatiu
participant SS as ServerSocket (9090)
participant Srv as Codi del servidor
participant C as Client
Srv->>SS: new ServerSocket(9090)
Note over SS,SO: bind: el port 9090 queda reservat<br/>listen: el SO comenca a acceptar SYN
Srv->>SS: accept()
Note over Srv: BLOQUEJAT esperant
C->>SO: SYN
SO->>C: SYN + ACK
C->>SO: ACK
Note over SO: Connexio ja establerta.<br/>Va a la cua de pendents (backlog).
SO-->>SS: hi ha una connexio a la cua
SS-->>Srv: retorna un Socket connectat
Note over Srv: A partir d'aqui, E/S normal
Srv->>C: 200 BIBLIOTECH BTCP/1
C->>Srv: CONSULTA 978-0000000001
Srv->>C: 200 OK ...
C->>Srv: SORTIR
Srv->>C: 221 ADEU
Srv->>Srv: socket.close()
Note over Srv: Torna a l'accept() a buscar el seguent
Fixa't en un detall importantíssim del diagrama: la salutació de tres vies la completa el sistema operatiu, no el teu codi. Quan el teu accept() retorna, la connexió ja estava establerta des d'abans. Això significa que un client pot connectar amb èxit encara que el teu servidor estigui ocupat i trigui a cridar accept() — quedarà esperant en una cua. Aquesta cua és el backlog.
- El
bind, el backlog i l'adreça d'escolta
bind, el backlog i l'adreça d'escoltaServerSocket té diversos constructors, i els seus paràmetres són exactament les tres decisions que cal prendre:
// 1. Port. El minim.
new ServerSocket(9090);
// 2. Port + backlog: mida de la cua de connexions pendents.
new ServerSocket(9090, 100);
// 3. Port + backlog + adreca d'escolta.
new ServerSocket(9090, 100, InetAddress.getByName("127.0.0.1"));
// 4. Sense connectar, per configurar abans del bind (ho veurem a l'apartat 4).
ServerSocket s = new ServerSocket();
s.setReuseAddress(true);
s.bind(new InetSocketAddress(9090), 100);El backlog
És la mida de la cua on el sistema operatiu guarda les connexions ja establertes que el teu codi encara no ha recollit amb accept().
cua de pendents (backlog = 3)
clients --> [ conn ][ conn ][ conn ] --> accept() --> el teu codi
nous (un cada vegada)
Si la cua esta plena, el SO rebutja les connexions noves
i el client rep ConnectException: Connection refused.- Valor per defecte: 50.
- Si el teu codi atén ràpid i torna de seguida a l'
accept(), la cua gairebé mai no s'omple. - Si el teu codi triga molt per connexió (servidor seqüencial), la cua s'omple i els clients comencen a ser rebutjats com si el servidor no existís.
- El sistema operatiu pot retallar el teu valor: a Linux està limitat per
net.core.somaxconn. Demanar-ne 10.000 no en garanteix 10.000.
El backlog és un amortidor per a ràfegues, no una solució a un servidor lent. Un backlog de 1000 en un servidor seqüencial només aconsegueix que mil clients esperin molt en lloc que nou-cents cinquanta rebin un error ràpid.
L'adreça d'escolta
El tercer paràmetre decideix per quines interfícies s'accepten connexions, i és una decisió de seguretat:
| Adreça | Efecte |
|---|---|
Omesa o 0.0.0.0 |
Escolta a totes les interfícies: accessible des de la xarxa |
127.0.0.1 |
Escolta només al bucle invertit: només des de la pròpia màquina |
192.168.1.50 |
Escolta només per aquella interfície concreta |
Durant el desenvolupament, lligar-se a 127.0.0.1 és un bon costum: garanteix que ningú de la xarxa no pot tocar el teu servidor a mig fer. En producció, BiblioTech escoltarà a 0.0.0.0 perquè la Marta i en Diego hi arribin des dels seus llocs. Ho farem configurable amb la Configuracio/ReglesNegoci que ja llegeix bibliotech.properties.
El port 0
try (ServerSocket servidor = new ServerSocket(0)) {
int portReal = servidor.getLocalPort(); // per exemple, 43127
System.out.println("El sistema m'ha donat el port " + portReal);
}Demanar el port 0 fa que el sistema n'assigni un de lliure qualsevol. És la tècnica estàndard en proves automatitzades: cada prova aixeca el seu servidor en un port que segur que és lliure, sense xocar amb altres proves ni amb processos del desenvolupador. Ho veuràs al mòdul 11 amb JUnit.
accept(): el mètode que lliura connexions
accept(): el mètode que lliura connexionsQuatre coses que cal saber d'aquest mètode:
- Bloqueja indefinidament fins que hi ha una connexió disponible. Un fil aturat a
accept()no consumeix CPU, però està aturat. - Retorna un
Socketja connectat. No cal cridarconnect()ni res: la conversa pot començar. - És interrompible només tancant el
ServerSocket.Thread.interrupt()no el desbloqueja. Aquest detall governa tota l'aturada ordenada, i el tractem a l'apartat 8. - Admet temps límit amb
serverSocket.setSoTimeout(ms), que fa que llanciSocketTimeoutExceptionen lloc d'esperar per sempre. Igual que a 09-02, elServerSocketcontinua vàlid després.
servidor.setSoTimeout(1000); // sondeig cada segon
while (executant) {
try {
Socket connexio = servidor.accept();
atendre(connexio);
} catch (SocketTimeoutException e) {
// Un segon sense connexions: aprofitem per comprovar
// el marcador d'aturada i tornem a esperar.
continue;
}
}Aquest patró —el mateix que el del setSoTimeout de 09-02— és una alternativa al tancament del ServerSocket per a l'aturada. És més suau però menys immediat: fins a un segon de retard en aturar. Totes dues tècniques es fan servir; a BiblioTech farem servir el tancament, que és instantani.
BindException: Address already in use i setReuseAddress
BindException: Address already in use i setReuseAddressAquest és el primer error que trobaràs, garantit, tan bon punt provis el servidor dues vegades seguides:
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 exactament el que diu: alguna cosa ja té reservat aquell port. Hi ha dues causes molt diferents.
Causa 1: un altre procés el té
El més habitual és una execució anterior del teu propi servidor que no vas tancar. Es diagnostica en un segon:
ss -tlnp | grep 9090
# LISTEN 0 50 0.0.0.0:9090 0.0.0.0:* users:(("java",pid=4711,fd=7))
kill 4711 # i si es resisteix, kill -9 4711A macOS i en sistemes sense ss:
Causa 2: TIME_WAIT
Aquesta és més subtil i més frustrant. Quan una connexió TCP es tanca, l'extrem que tanca primer deixa la connexió en estat TIME_WAIT durant un parell de minuts. És intencionat: serveix perquè els paquets endarrerits d'aquella connexió no confonguin una connexió nova amb la mateixa quàdrupla.
L'efecte és que pots aturar el teu servidor i no poder tornar a arrencar-lo durant dos minuts, encara que ss no mostri cap procés escoltant.
La solució és SO_REUSEADDR:
// La forma correcta: crear SENSE lligar, configurar, i despres lligar.
ServerSocket servidor = new ServerSocket();
servidor.setReuseAddress(true); // ABANS del bind
servidor.bind(new InetSocketAddress(9090), 100);setReuseAddress(true) s'ha de fixar abans del bind, i per això cal fer servir el constructor sense arguments. Si fas servir new ServerSocket(9090) el bind ja ha passat i cridar setReuseAddress després no serveix de res.
Avís.
SO_REUSEADDRpermet lligar un port que està enTIME_WAIT, però no permet lligar un port que un altre procés està escoltant activament. Si continues tenintBindExceptionambsetReuseAddress(true), la causa és la número 1 i tocass -tlnp.
A BiblioTech, el ServerSocket es crearà sempre amb aquest patró de tres passos. És una d'aquelles coses que no costen res i estalvien molta frustració.
- El servidor seqüencial i la demostració del seu límit
Comencem pel més simple que funciona, per veure per què no n'hi ha prou.
package com.nexussoftware.bibliotech.xarxa;
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 d'eco SEQUENCIAL: aten un client cada vegada.
* Serveix per demostrar el limit del model, no per fer-lo servir.
*/
public class ServidorSequencial {
private static final Logger LOG = Logger.getLogger(ServidorSequencial.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 sequencial escoltant al 9090");
try (servidor) {
while (true) {
// S'accepta UNA connexio...
Socket connexio = servidor.accept();
LOG.info(() -> "Connexio de " + connexio.getRemoteSocketAddress());
// ...i s'aten SENCERA abans de tornar a l'accept().
// Mentre dura aquesta conversa, no s'aten ningu mes.
try (connexio) {
connexio.setSoTimeout(60_000);
atendre(connexio);
} catch (IOException e) {
LOG.warning("Fallada atenent la connexio: " + e.getMessage());
}
LOG.info("Connexio acabada; torno a l'accept()");
}
}
}
private static void atendre(Socket connexio) throws IOException {
BufferedReader lector = new BufferedReader(
new InputStreamReader(connexio.getInputStream(), StandardCharsets.UTF_8));
PrintWriter escriptor = new PrintWriter(
new java.io.BufferedWriter(
new java.io.OutputStreamWriter(connexio.getOutputStream(),
StandardCharsets.UTF_8)),
true);
escriptor.println("200 SERVIDOR D'ECO. Escriu SORTIR per acabar.");
String linia;
while ((linia = lector.readLine()) != null) {
if (linia.equalsIgnoreCase("SORTIR")) {
escriptor.println("221 ADEU");
return;
}
escriptor.println("ECO: " + linia);
}
// readLine() ha retornat null: el client ha tancat sense acomiadar-se.
}
}La demostració
Arrenca el servidor i obre tres terminals amb telnet localhost 9090:
TERMINAL 1 (primer client) TERMINAL 2 (segon client)
------------------------------ ------------------------------
$ telnet localhost 9090 $ telnet localhost 9090
Trying 127.0.0.1... Trying 127.0.0.1...
Connected to localhost. Connected to localhost.
200 SERVIDOR D'ECO. Escriu... _
hola (res. No hi ha salutacio.
ECO: hola La connexio TCP esta feta,
pero el servidor no ha cridat
accept() i no llegeix res)El segon client connecta, perquè el sistema operatiu completa la salutació de tres vies i el deixa al backlog. Però no rep la salutació del protocol ni resposta a res, perquè el fil únic del servidor continua ficat a la conversa del primer.
Escriu SORTIR a la terminal 1 i observa la terminal 2:
TERMINAL 1 TERMINAL 2
------------------------------ ------------------------------
SORTIR 200 SERVIDOR D'ECO. Escriu...
221 ADEU (ara si! En alliberar-se el fil,
Connection closed. l'accept() recull aquesta connexio)Aquesta és la demostració exacta del problema. El log del servidor ho confirma:
INFO: Servidor sequencial escoltant al 9090
INFO: Connexio de /127.0.0.1:52310
INFO: Connexio acabada; torno a l'accept()
INFO: Connexio de /127.0.0.1:52311Les dues connexions es van establir gairebé alhora, però el servidor les va processar una darrere l'altra.
Per què és inacceptable
| Problema | Conseqüència |
|---|---|
| Un client lent bloqueja tothom | Si la Marta se'n va a dinar amb el telnet obert, ningú més no consulta |
El backlog s'omple |
Amb més de 50 esperant, els següents reben ConnectException |
| Un client maliciós mata el servei | Connectar i no enviar res ja n'hi ha prou per deixar el servidor inútil fins al setSoTimeout |
| No aprofita la màquina | Un servidor de 16 nuclis fent servir-ne un |
I fixa't en el tercer punt, perquè és el més greu: un sol client que connecti i es calli deixa el servei caigut durant 60 segons. Sense setSoTimeout, per sempre. Un servidor seqüencial és un servidor que qualsevol pot tombar des d'una terminal.
- El servidor concurrent: un fil per connexió
La solució òbvia, i el primer pas correcte: per cada connexió acceptada, llançar un fil que l'atengui, i tornar immediatament a l'accept().
while (true) {
Socket connexio = servidor.accept();
// Un fil nou per connexio. El bucle torna a l'accept() a l'instant.
Thread fil = new Thread(() -> {
try (connexio) {
atendre(connexio);
} catch (IOException e) {
LOG.warning("Fallada atenent: " + e.getMessage());
}
}, "client-" + connexio.getPort());
fil.start();
}Funciona. Amb això, les tres terminals de telnet són ateses alhora. Però té un problema d'escalabilitat seriós, i és el mateix que ja vas veure a 08-05 quan es va explicar per què existeixen els pools:
| Problema d'"un fil per connexió" | Detall |
|---|---|
| Cost de memòria | Cada fil de plataforma reserva pila pròpia: 512 KB - 1 MB. Mil clients són 1 GB només en piles |
| Cost de creació | Crear un fil costa de l'ordre de desenes de microsegons; amb milers de connexions curtes, es nota |
| Cost de planificació | Amb molts més fils que nuclis, el sistema passa temps canviant de context en lloc de treballar |
| Sense límit | El pitjor. Res no impedeix que deu mil connexions creïn deu mil fils i tombin la JVM amb OutOfMemoryError: unable to create new native thread |
L'últim punt converteix això en una vulnerabilitat de denegació de servei: un atacant obre connexions en bucle i el teu servidor se suïcida creant fils. No és teòric; és l'atac més barat que existeix contra un servidor així.
- La solució correcta:
ExecutorService
ExecutorServiceAquí és on el mòdul 8 rendeix de veritat. Un pool acotat resol els quatre problemes de cop: els fils es reutilitzen (sense cost de creació), són un nombre fix (memòria acotada i planificació raonable) i, sobretot, hi ha un límit: si arriben més connexions de les que hi caben, s'encuen o es rebutgen, però la JVM no cau.
package com.nexussoftware.bibliotech.xarxa;
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 fils amb noms llegibles. Un fil anomenat "bibliotech-client-3"
* en un bolcat de fils o en una linia de log val mil vegades mes que
* "pool-1-thread-3". Aixo es 08-02 i 08-05 aplicats.
*/
public class FabricaFilsServidor implements ThreadFactory {
private final String prefix;
private final AtomicInteger comptador = new AtomicInteger(1);
public FabricaFilsServidor(String prefix) {
this.prefix = prefix;
}
@Override
public Thread newThread(Runnable r) {
Thread fil = new Thread(r, prefix + "-" + comptador.getAndIncrement());
// NO daemon: volem que l'aturada sigui explicita i ordenada,
// no que la JVM mati converses a mitges en acabar el main.
fil.setDaemon(false);
return fil;
}
}ThreadFactory és una interfície funcional amb un únic mètode, newThread, així que també es pot escriure com a lambda (04-05); aquí va com a classe amb nom perquè necessita el comptador com a estat.
La creació del pool, amb tots els paràmetres explícits:
// Pool acotat amb ThreadPoolExecutor: control total sobre el comportament.
ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, // fils nucli: sempre vius
8, // maxim: acotat, sense sorpreses
60L, TimeUnit.SECONDS, // temps ocios abans de morir
new LinkedBlockingQueue<>(100), // cua ACOTADA de tasques en espera
new FabricaFilsServidor("bibliotech-client"),
new ThreadPoolExecutor.AbortPolicy()); // que fer si no hi capLes dues decisions que més importen:
La cua ha de ser acotada. Executors.newFixedThreadPool(8) fa servir per dins una LinkedBlockingQueue sense límit. Això significa que si arriben cent mil connexions, les cent mil s'encuen i et quedes sense memòria — has canviat un OutOfMemoryError per fils per un OutOfMemoryError per tasques encuades. Una cua de 100 diu "puc tenir 8 converses en curs i 100 esperant; a partir d'aquí, rebutjo".
La política de rebuig ha de ser conscient. Quan el pool és ple i la cua també:
| Política | Què fa | Quan fer-la servir |
|---|---|---|
AbortPolicy (per defecte) |
Llança RejectedExecutionException |
Quan vols rebutjar explícitament i dir-ho al client |
CallerRunsPolicy |
L'executa el fil que va cridar submit |
Contrapressió: frena l'acceptador. Perillós en un servidor: el fil de l'accept es posa a atendre un client i deixa d'acceptar |
DiscardPolicy |
La llença en silenci | Gairebé mai: pèrdua silenciosa |
DiscardOldestPolicy |
Llença la més vella de la cua | Gairebé mai en servidors |
Per a un servidor, AbortPolicy és el correcte: captures l'excepció, respons al client alguna cosa com 503 SERVIDOR SATURAT i tanques educadament. És infinitament millor que acceptar i no respondre.
try {
pool.execute(() -> atendreConnexio(connexio));
} catch (RejectedExecutionException e) {
// El servidor esta saturat. Li ho diem al client i tanquem.
// Rebutjar rapid es MILLOR que acceptar i no respondre.
rebutjarEducadament(connexio, "503 SERVIDOR SATURAT");
}El diagrama del servidor concurrent
graph TD
A["Fil principal<br/>bucle accept()"] -->|Socket 1| B["ExecutorService<br/>pool de 8 fils"]
A -->|Socket 2| B
A -->|Socket 3| B
A -->|Socket N| B
B --> C["client-1<br/>conversa BTCP"]
B --> D["client-2<br/>conversa BTCP"]
B --> E["client-3<br/>conversa BTCP"]
C --> F["CatalegConcurrent<br/>ConcurrentHashMap"]
D --> F
E --> F
B -->|pool i cua plens| G["RejectedExecutionException<br/>503 SERVIDOR SATURAT"]
El fil principal no fa cap altra cosa que acceptar. Recull la connexió, la lliura al pool i torna a l'accept() en microsegons. Tota la conversa —que pot durar minuts— passa en un fil del pool. Aquesta separació és el que fa que un servidor escali.
Quants fils
La regla de 08-05 continua valent, i aquí és especialment favorable. Un fil que atén una connexió passa la immensa majoria del temps bloquejat esperant que el client escrigui alguna cosa. No consumeix CPU. Per això un servidor de xarxa admet molts més fils que nuclis:
- Per a treball intensiu en CPU:
nuclisonuclis + 1. - Per a treball amb molta espera d'E/S (això): entre
nuclis × 4inuclis × 20, segons quant s'esperi. - Amb BTCP/1 i una xarxa local, unes desenes basten per a centenars de clients esporàdics.
BiblioTech farà servir un pool configurable, amb valor per defecte de 16 fils, llegit de bibliotech.properties.
- Tancar el
ServerSocket per desbloquejar l'accept
ServerSocket per desbloquejar l'acceptAquí hi ha un detall que sorprèn tothom la primera vegada:
accept()no respon aThread.interrupt().
Un fil bloquejat a accept() ignora completament la interrupció. El marcador d'interrupció es marca, però el fil continua esperant. El protocol de cancel·lació cooperativa de 08-02, que funciona perfectament amb sleep, wait i BlockingQueue.take, no serveix aquí.
L'única forma de desbloquejar un accept() és tancar el ServerSocket des d'un altre fil. En fer-ho, l'accept() llança immediatament una SocketException:
private volatile boolean executant = true;
private ServerSocket servidor;
/** Bucle principal. S'executa al fil acceptador. */
public void executar() {
while (executant) {
try {
Socket connexio = servidor.accept();
pool.execute(() -> atendre(connexio));
} catch (SocketException e) {
// Pot ser una fallada real O el nostre propi tancament.
// El marcador 'executant' distingeix els dos casos:
// sense ell, una aturada normal apareix al log com un error greu.
if (!executant) {
LOG.info("ServerSocket tancat: fi ordenat del bucle d'acceptacio");
break;
}
LOG.log(Level.SEVERE, "Fallada inesperada a accept()", e);
break;
} catch (IOException e) {
// Una fallada acceptant UNA connexio no ha de tombar el servidor:
// es registra i es continua acceptant.
LOG.log(Level.WARNING, "Error acceptant una connexio", e);
}
}
}
/** Es crida des d'un altre fil (menu, shutdown hook, senyal). */
public void aturar() {
executant = false; // 1. marcador volatile: es veu des de l'altre fil
tancarSilenciosament(servidor); // 2. aixo desbloqueja l'accept() a l'instant
// 3. i despres, l'aturada en dues fases del pool
}Els tres elements són necessaris i cap no sobra:
- El marcador
volatiledistingeix el tancament intencionat d'una fallada real. Sense ell, cada aturada neta deixa unSEVEREamb traça al log, i acabes ignorant elsSEVERE. - Tancar el
ServerSocketés el que desbloqueja. El marcador sol no faria res, perquè el fil és dins de l'accept()i no el torna a mirar. - L'ordre importa: primer el marcador, després el tancament. A l'inrevés, hi ha una finestra en què l'
accept()llança l'excepció iexecutantencara éstrue, i l'aturada neta es registra com a error.
L'aturada en dues fases del pool
Tancar el ServerSocket impedeix connexions noves, però no toca les converses en curs. Per a aquestes, l'aturada en dues fases de 08-05:
public void aturar() {
executant = false;
tancarSilenciosament(servidor);
// FASE 1: no s'accepten tasques noves, pero les que estan en curs acaben.
pool.shutdown();
try {
// Donem un termini raonable perque les converses acabin soles.
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
LOG.warning("Converses encara actives despres de 10 s: es forca el tancament");
// FASE 2: interrupcio dels fils que quedin.
pool.shutdownNow();
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
LOG.severe("El pool no ha acabat; hi ha fils encallats");
}
}
} catch (InterruptedException e) {
// Ens han interromput esperant: forcem i RESTAUREM el marcador.
pool.shutdownNow();
Thread.currentThread().interrupt(); // 08-02: mai no s'empassa la interrupcio
}
}Detall important i sovint oblidat.
shutdownNow()interromp els fils del pool, però un fil bloquejat asocket.read()tampoc no respon a la interrupció — igual queaccept(). Per això el servidor posasetSoTimeouta cada connexió de client: converteix el bloqueig etern en unaSocketTimeoutExceptionperiòdica, que és un punt on el fil pot comprovar el seu marcador d'interrupció i sortir. SensesetSoTimeout,shutdownNow()no aconsegueix aturar un client callat i l'awaitTerminations'esgota. Aquesta és la raó real, i pràctica, de per què el temps límit de lectura no és opcional en un servidor.
- Estat compartit entre connexions
Amb un pool de 16 fils atenent connexions, 16 fils accedeixen al catàleg alhora. Això no és un cas excepcional: és el funcionament normal del servidor.
I aquí arriba la recompensa de dos mòduls de feina. CatalegConcurrent, que vas escriure a 08-06 amb ConcurrentHashMap, CopyOnWriteArrayList i LongAdder, ja està preparat per a això. No cal tocar ni una sola línia:
// Dins del fil que aten la Marta, i alhora dins del que aten en Diego,
// i alhora dins del que aten la Nuria:
Material material = cataleg.cercarPerIsbn(isbn); // segur: ConcurrentHashMap
cataleg.registrarConsulta(); // segur: LongAdder| Estat compartit de BiblioTech | Estructura | Per què és segur |
|---|---|---|
| Catàleg de materials | ConcurrentHashMap a CatalegConcurrent |
Lectures sense bloqueig, escriptures atòmiques per segment |
| Comptadors de consultes | LongAdder a EstadistiquesBiblioTech |
Increments atòmics amb contenció mínima |
| Registre de préstecs | ReadWriteLock a RegistrePrestecsSegur |
Invariant entre dos mapes sota un únic pany |
| Llista d'oients | CopyOnWriteArrayList |
Moltes lectures, escriptures rares |
| Configuració | Objecte immutable carregat en arrencar | Immutable = segur per construcció |
El que sí que cal vigilar:
- Les operacions compostes continuen sense ser atòmiques.
if (cataleg.estaDisponible(isbn)) cataleg.marcarPrestat(isbn)té una cursa entre les dues crides: dos clients poden passar l'ifalhora i tots dos prestar el mateix llibre. La solució, ja coneguda de 08-06, és exposar l'operació completa com un mètode atòmic del servei (RegistrePrestecsSegur.prestarSiDisponible(...)), no encadenar dues operacions segures. - Cada connexió ha de tenir el seu propi estat local. Els
BufferedReader,PrintWriteri variables de la conversa són d'aquella conversa. Mai camps d'instància del servidor compartits entre fils. Aquest error —fer servir un camp del servidor per a l'escriptor de "el" client— produeix respostes que arriben al client equivocat, i és un bug espectacular de diagnosticar. - Els comptadors de connexions actives han de ser atòmics:
AtomicInteger.
- BiblioTech: el servidor de catàleg complet
Ara sí. Tot junt.
package com.nexussoftware.bibliotech.xarxa;
import com.nexussoftware.bibliotech.domini.Material;
import com.nexussoftware.bibliotech.servei.CatalegConcurrent;
import com.nexussoftware.bibliotech.servei.RegistrePrestecsSegur;
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 protocol BTCP/1 de BiblioTech.
*
* - Un fil acceptador que nomes accepta i delega.
* - Un pool ACOTAT de fils que atenen les converses (modul 8).
* - Estat compartit a CatalegConcurrent (ConcurrentHashMap): ja era segur.
* - Validacio estricta de tot el que arriba per la xarxa.
* - setSoTimeout per connexio per expulsar clients inactius.
* - Aturada ordenada en dues fases.
*/
public class ServidorCataleg implements AutoCloseable {
private static final Logger LOG = Logger.getLogger(ServidorCataleg.class.getName());
// --- Limits defensius: tots els valors que venen de la xarxa s'acoten ---
/** Cap peticio BTCP/1 legitima no passa d'aixo. Evita l'esgotament de memoria. */
private static final int MAXIMA_LONGITUD_LINIA = 512;
/** Peticions seguides per connexio: evita que un client monopolitzi un fil. */
private static final int MAXIM_PETICIONS = 1_000;
/** Un ISBN de BiblioTech te aquesta forma i res mes. */
private static final int MAXIM_ISBN = 20;
private static final int MAXIM_EMPLEAT = 60;
private final int port;
private final String adrecaEscolta;
private final int fils;
private final int cuaEspera;
private final int limitInactivitatMs;
private final CatalegConcurrent cataleg;
private final RegistrePrestecsSegur prestecs;
private ServerSocket servidor;
private ThreadPoolExecutor pool;
/** volatile: l'escriu el fil que atura, la llegeix el fil acceptador (08-04). */
private volatile boolean executant = false;
private final AtomicInteger connexionsActives = new AtomicInteger();
private final AtomicLong connexionsTotals = new AtomicLong();
private final AtomicLong peticionsAteses = new AtomicLong();
private final AtomicLong connexionsRebutjades = new AtomicLong();
public ServidorCataleg(int port, String adrecaEscolta, int fils,
int cuaEspera, int limitInactivitatMs,
CatalegConcurrent cataleg,
RegistrePrestecsSegur prestecs) {
this.port = port;
this.adrecaEscolta = adrecaEscolta;
this.fils = fils;
this.cuaEspera = cuaEspera;
this.limitInactivitatMs = limitInactivitatMs;
this.cataleg = cataleg;
this.prestecs = prestecs;
}
// =================================================================
// Arrencada
// =================================================================
/** Prepara el socket i el pool. No bloqueja. */
public void arrencar() throws IOException {
// Patro de tres passos: crear sense lligar, configurar, lligar.
// setReuseAddress HA d'anar abans del bind, o no serveix de res.
servidor = new ServerSocket();
servidor.setReuseAddress(true);
servidor.bind(new InetSocketAddress(adrecaEscolta, port), cuaEspera);
ThreadFactory fabrica = new ThreadFactory() {
private final AtomicInteger n = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
// Noms llegibles: en un bolcat de fils i en cada linia
// de log sabras exactament qui fa que (08-02).
Thread f = new Thread(r, "bibliotech-client-" + n.getAndIncrement());
f.setDaemon(false);
return f;
}
};
pool = new ThreadPoolExecutor(
fils, fils,
60L, TimeUnit.SECONDS,
// Cua ACOTADA: newFixedThreadPool en fa servir una sense limit i aixo
// converteix una saturacio en un OutOfMemoryError.
new LinkedBlockingQueue<>(cuaEspera),
fabrica,
// Rebutjar rapid i dir-ho al client es millor que
// acceptar i no respondre.
new ThreadPoolExecutor.AbortPolicy());
executant = true;
LOG.info(() -> "ServidorCataleg BTCP/1 escoltant a "
+ adrecaEscolta + ":" + port
+ " (pool=" + fils + ", cua=" + cuaEspera + ")");
}
/** Bucle d'acceptacio. Bloqueja fins que es crida close(). */
public void executar() {
while (executant) {
Socket connexio = null;
try {
connexio = servidor.accept(); // BLOQUEJA aqui
// Temps limit d'INACTIVITAT per connexio. Es el que expulsa
// els clients que connecten i es callen, i a mes el que
// permet que shutdownNow() aconsegueixi aturar els fils.
connexio.setSoTimeout(limitInactivitatMs);
connexio.setTcpNoDelay(true); // protocol interactiu: sense Nagle
final Socket acceptada = connexio;
connexionsTotals.incrementAndGet();
// El fil acceptador NO aten: delega i torna a l'accept().
pool.execute(() -> atendreConnexio(acceptada));
} catch (RejectedExecutionException e) {
// Pool i cua plens: el servidor esta saturat.
connexionsRebutjades.incrementAndGet();
LOG.warning("Servidor saturat; connexio rebutjada");
rebutjar(connexio, "503 SERVIDOR SATURAT");
} catch (SocketException e) {
// El nostre propi close() O una fallada real. El marcador decideix.
if (!executant) {
LOG.info("Bucle d'acceptacio acabat ordenadament");
return;
}
LOG.log(Level.SEVERE, "Fallada inesperada a accept()", e);
return;
} catch (IOException e) {
// Una fallada acceptant UNA connexio no tomba el servidor.
LOG.log(Level.WARNING, "Error acceptant una connexio", e);
tancarSilenciosament(connexio);
}
}
}
// =================================================================
// Atencio d'una connexio (s'executa en un fil del pool)
// =================================================================
private void atendreConnexio(Socket connexio) {
String client = String.valueOf(connexio.getRemoteSocketAddress());
int actives = connexionsActives.incrementAndGet();
LOG.info(() -> "[" + client + "] connectat (" + actives + " actives)");
// try-with-resources: el socket es tanca passi el que passi.
try (connexio) {
BufferedReader lector = new BufferedReader(
new InputStreamReader(connexio.getInputStream(),
StandardCharsets.UTF_8));
PrintWriter escriptor = new PrintWriter(
new BufferedWriter(
new OutputStreamWriter(connexio.getOutputStream(),
StandardCharsets.UTF_8)),
false); // sense autoFlush: buidem a ma despres de cada resposta
// El servidor parla primer, com especifica el protocol.
respondre(escriptor, "200 BIBLIOTECH BTCP/1");
int peticions = 0;
while (!Thread.currentThread().isInterrupted()) {
if (peticions++ >= MAXIM_PETICIONS) {
// Un client no pot quedar-se amb un fil indefinidament.
respondre(escriptor, "421 MASSA PETICIONS");
LOG.warning("[" + client + "] ha superat el limit de peticions");
return;
}
String linia = llegirLiniaAcotada(lector);
if (linia == null) {
LOG.info(() -> "[" + client + "] ha tancat la connexio");
return;
}
peticionsAteses.incrementAndGet();
boolean continuar = processar(linia, escriptor, client);
if (!continuar) {
return;
}
}
} catch (SocketTimeoutException e) {
// El client ha connectat i s'ha callat. Se l'expulsa: el fil torna al pool.
LOG.info(() -> "[" + client + "] expulsat per inactivitat ("
+ limitInactivitatMs + " ms)");
} catch (LiniaMassaLlargaException e) {
LOG.warning("[" + client + "] ha enviat una linia de mes de "
+ MAXIMA_LONGITUD_LINIA + " caracters; connexio tallada");
} catch (SocketException e) {
// "Connection reset": el client ha caigut. Es normal, no es un error greu.
LOG.info(() -> "[" + client + "] connexio perduda: " + e.getMessage());
} catch (IOException e) {
LOG.log(Level.WARNING, "[" + client + "] fallada d'E/S", e);
} catch (RuntimeException e) {
// Una fallada de programacio en la gestio d'UN client no pot
// matar el fil del pool ni afectar els altres. Aquest catch
// es la frontera d'errors de 06-07 aplicada a cada connexio.
LOG.log(Level.SEVERE, "[" + client + "] fallada inesperada", e);
} finally {
int queden = connexionsActives.decrementAndGet();
LOG.info(() -> "[" + client + "] desconnectat (" + queden + " actives)");
}
}
// =================================================================
// Processament d'una peticio
// =================================================================
/** Retorna false si cal tancar la connexio despres d'aquesta peticio. */
private boolean processar(String linia, PrintWriter escriptor, String client) {
// Se separa l'ordre de la resta pel PRIMER espai: el nom
// d'empleat pot portar espais i no el volem partir.
String ordre;
String resta;
int espai = linia.indexOf(' ');
if (espai < 0) {
ordre = linia;
resta = "";
} else {
ordre = linia.substring(0, espai);
resta = linia.substring(espai + 1).strip();
}
// Les ordres es comparen en majuscules per ser tolerants,
// pero el protocol les documenta en majuscules.
switch (ordre.toUpperCase()) {
case "CONSULTA" -> {
if (!isbnValid(resta)) {
respondre(escriptor, "400 PETICIO INVALIDA isbn no valid");
return true;
}
Material material = cataleg.cercarPerIsbn(resta);
if (material == null) {
respondre(escriptor, "404 NO TROBAT " + resta);
} else {
respondre(escriptor, "200 OK " + formatar(material));
}
return true;
}
case "LLISTA" -> {
if (!resta.isEmpty()) {
respondre(escriptor, "400 PETICIO INVALIDA LLISTA no admet arguments");
return true;
}
List<Material> materials = cataleg.tots();
respondre(escriptor, "201 LLISTA " + materials.size());
for (Material m : materials) {
respondre(escriptor, formatar(m));
}
respondre(escriptor, ".");
return true;
}
case "PRESTAR" -> {
int sep = resta.indexOf(' ');
if (sep < 0) {
respondre(escriptor, "400 PETICIO INVALIDA falten arguments");
return true;
}
String isbn = resta.substring(0, sep);
String empleat = resta.substring(sep + 1).strip();
if (!isbnValid(isbn) || !empleatValid(empleat)) {
respondre(escriptor, "400 PETICIO INVALIDA arguments no valids");
return true;
}
if (cataleg.cercarPerIsbn(isbn) == null) {
respondre(escriptor, "404 NO TROBAT " + isbn);
return true;
}
// OPERACIO COMPOSTA ATOMICA: comprovar disponibilitat i prestar
// en una sola crida al servei. Encadenar dues operacions
// segures NO dona una operacio segura (08-06).
boolean prestat = prestecs.prestarSiDisponible(isbn, empleat);
if (prestat) {
LOG.info(() -> "[" + client + "] prestec de " + isbn
+ " a " + empleat);
respondre(escriptor, "200 OK prestec registrat");
} else {
respondre(escriptor, "409 NO DISPONIBLE " + isbn);
}
return true;
}
case "SORTIR" -> {
respondre(escriptor, "221 ADEU");
return false; // tancar la connexio
}
default -> {
// NO retornem l'ordre tal qual al client sense sanejar:
// podria contenir sequencies d'escapada de terminal.
respondre(escriptor, "400 PETICIO INVALIDA ordre desconeguda: "
+ sanejar(ordre));
return true;
}
}
}
private String formatar(Material m) {
return m.getIsbn() + "|" + m.getTitol() + "|"
+ m.getTipus() + "|" + m.estaDisponible();
}
// =================================================================
// E/S de baix nivell del protocol
// =================================================================
/** Escriu una linia del protocol i BUIDA. Sense flush, el client espera eternament. */
private void respondre(PrintWriter escriptor, String linia) {
escriptor.print(linia + "\n"); // \n explicit: el protocol ho exigeix
escriptor.flush(); // OBLIGATORI
if (escriptor.checkError()) {
// PrintWriter s'empassa les IOException; checkError es l'unica pista.
LOG.fine("Fallada en escriure la resposta; el client probablement ha tancat");
}
}
/** Excepcio interna per a una linia que excedeix el limit. */
private static class LiniaMassaLlargaException extends IOException {
LiniaMassaLlargaException(String m) {
super(m);
}
}
/**
* Llegeix una linia ACOTADA. BufferedReader.readLine() no te limit:
* un client malicios pot enviar 100 MB sense ni un salt de linia
* i esgotar la memoria del servidor. Aquesta es una defensa obligatoria
* en qualsevol servidor exposat.
*/
private String llegirLiniaAcotada(BufferedReader lector) throws IOException {
StringBuilder sb = new StringBuilder();
int c;
while ((c = lector.read()) != -1) {
if (c == '\n') {
// Acceptem tambe \r\n traient el retorn de carro final.
int fi = sb.length();
if (fi > 0 && sb.charAt(fi - 1) == '\r') {
sb.setLength(fi - 1);
}
return sb.toString();
}
if (sb.length() >= MAXIMA_LONGITUD_LINIA) {
throw new LiniaMassaLlargaException(
"Linia de mes de " + MAXIMA_LONGITUD_LINIA + " caracters");
}
sb.append((char) c);
}
// Fi de flux. Si hi havia alguna cosa a mitges, es descarta: no es una linia valida.
return null;
}
// =================================================================
// Validacio: NO confiar MAI en el que arriba per la xarxa
// =================================================================
private boolean isbnValid(String isbn) {
if (isbn == null || isbn.isBlank() || isbn.length() > MAXIM_ISBN) {
return false;
}
// Llista BLANCA de caracters: nomes digits i guions. Tota la resta fora.
// Una llista blanca es sempre mes segura que una llista 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 empleatValid(String empleat) {
if (empleat == null || empleat.isBlank() || empleat.length() > MAXIM_EMPLEAT) {
return false;
}
for (int i = 0; i < empleat.length(); i++) {
char c = empleat.charAt(i);
// Lletres, digits, espais, guions baixos i guions. Res de
// caracters de control, que trencarien el protocol o el log.
if (!Character.isLetterOrDigit(c) && c != ' ' && c != '_' && c != '-') {
return false;
}
}
return true;
}
/**
* Saneja un text que ve de la xarxa abans de retornar-lo o registrar-lo.
* Sense aixo, un client pot injectar \n (una resposta falsa al
* protocol) o sequencies d'escapada ANSI (que manipulen el terminal
* de qui llegeixi el log). Es INJECCIO DE LOG, un problema real.
*/
private String sanejar(String text) {
StringBuilder sb = new StringBuilder();
int limit = Math.min(text.length(), 40);
for (int i = 0; i < limit; i++) {
char c = text.charAt(i);
sb.append(Character.isISOControl(c) ? '?' : c);
}
return sb.toString();
}
// =================================================================
// Aturada ordenada
// =================================================================
@Override
public void close() {
if (!executant) {
return;
}
LOG.info("Apagant ServidorCataleg...");
// 1. Marcador ABANS del tancament: distingeix l'aturada neta d'una fallada.
executant = false;
// 2. Tancar el ServerSocket es l'UNICA cosa que desbloqueja accept().
// Thread.interrupt() no ho aconsegueix.
tancarSilenciosament(servidor);
// 3. Aturada en dues fases del pool (08-05).
if (pool != null) {
pool.shutdown(); // no admet tasques noves; les actuals continuen
try {
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
LOG.warning("Converses actives despres de 10 s: es forca el tancament");
pool.shutdownNow(); // interromp els fils
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
LOG.severe("Queden fils sense acabar");
}
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt(); // 08-02: restaurar el marcador
}
}
LOG.info(() -> String.format(
"ServidorCataleg aturat. Connexions: %d totals, %d rebutjades. "
+ "Peticions ateses: %d",
connexionsTotals.get(), connexionsRebutjades.get(),
peticionsAteses.get()));
}
private void rebutjar(Socket connexio, String missatge) {
if (connexio == null) {
return;
}
// Cortesia: dir al client per que se li tanca, en lloc de
// tallar-lo sense explicacio. Costa dues linies i estalvia suport.
try (connexio) {
PrintWriter escriptor = new PrintWriter(
new OutputStreamWriter(connexio.getOutputStream(),
StandardCharsets.UTF_8));
escriptor.print(missatge + "\n");
escriptor.flush();
} catch (IOException e) {
LOG.fine("No s'ha pogut notificar el rebuig: " + e.getMessage());
}
}
private void tancarSilenciosament(java.io.Closeable recurs) {
if (recurs != null) {
try {
recurs.close();
} catch (IOException ignorada) {
// Tancant ja no hi ha res a salvar.
}
}
}
// --- Metriques per al menu de BiblioTech ---
public int connexionsActives() {
return connexionsActives.get();
}
public long connexionsTotals() {
return connexionsTotals.get();
}
public long peticionsAteses() {
return peticionsAteses.get();
}
}La classe d'arrencada
package com.nexussoftware.bibliotech.presentacio;
import com.nexussoftware.bibliotech.persistencia.Configuracio;
import com.nexussoftware.bibliotech.xarxa.ServidorCataleg;
import com.nexussoftware.bibliotech.servei.CatalegConcurrent;
import com.nexussoftware.bibliotech.servei.RegistrePrestecsSegur;
import com.nexussoftware.bibliotech.infra.ConfiguracioLog;
import java.io.IOException;
import java.util.logging.Logger;
/** Arrencada del servidor de cataleg de BiblioTech. */
public class ServidorBiblioTechApp {
private static final Logger LOG = Logger.getLogger(ServidorBiblioTechApp.class.getName());
public static void main(String[] args) throws IOException {
ConfiguracioLog.inicialitzar(); // el logger del modul 6, ja configurat
// La configuracio surt de bibliotech.properties (07-07).
Configuracio config = Configuracio.carregar();
int port = config.enter("xarxa.port", 9090);
String escolta = config.text("xarxa.escolta", "0.0.0.0");
int fils = config.enter("xarxa.fils", 16);
int cua = config.enter("xarxa.cua", 100);
int inactivitat = config.enter("xarxa.inactivitat.ms", 60_000);
CatalegConcurrent cataleg = CatalegConcurrent.carregarDesDeFitxer();
RegistrePrestecsSegur prestecs = new RegistrePrestecsSegur(cataleg);
ServidorCataleg servidor = new ServidorCataleg(
port, escolta, fils, cua, inactivitat, cataleg, prestecs);
servidor.arrencar();
// Shutdown hook: s'executa amb Ctrl+C o amb SIGTERM (que es el que
// envia Docker, systemd o Kubernetes en aturar un contenidor).
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
LOG.info("Senyal d'aturada rebut");
servidor.close();
}, "bibliotech-aturada"));
// Bloqueja aqui fins que el ServerSocket es tanqui.
servidor.executar();
LOG.info("Servidor acabat");
}
}
- Validació de l'entrada: no confiar mai en la xarxa
Val la pena aturar-se en això, perquè és la diferència entre un exercici i un servidor que es pot posar en producció.
Tot el que arriba per un socket és entrada no fiable. No importa que sigui la xarxa interna de Nexus Software: aquella xarxa inclou el portàtil d'un becari, el mòbil d'un visitant i qualsevol equip compromès. Un client pot ser un programa diferent del teu, una versió antiga, o algú amb nc i curiositat.
Aquestes són les defenses que porta ServidorCataleg i quin atac evita cadascuna:
| Defensa | Atac que evita |
|---|---|
MAXIMA_LONGITUD_LINIA amb lectura acotada |
Un client envia 100 MB sense \n i esgota la memòria del servidor |
MAXIM_PETICIONS per connexió |
Un client monopolitza un fil del pool indefinidament |
setSoTimeout per connexió |
Clients que connecten i es callen esgoten el pool |
| Pool i cua acotats | Milers de connexions simultànies tomben la JVM |
isbnValid amb llista blanca de caràcters |
Injecció de dades estranyes al catàleg, al log o a la resposta |
sanejar() abans de retornar o registrar |
Injecció de salts de línia (respostes falses) i d'escapades ANSI al log |
Operació composta atòmica (prestarSiDisponible) |
Dos clients presten el mateix llibre simultàniament |
catch (RuntimeException) per connexió |
Una fallada amb un client mata el fil i afecta els altres |
Dos principis generals:
Llista blanca, mai llista negra. isbnValid accepta només dígits i guions. L'alternativa —rebutjar caràcters perillosos— sempre es queda curta, perquè la llista de caràcters perillosos creix amb cada context nou. Defineix el que acceptes i rebutja la resta.
Sanejar també el que surt. Si retornes al client l'ordre que va enviar, i aquella ordre conté \n200 OK fals, has permès que injecti una resposta al teu protocol. I si l'escrius al log sense sanejar, un atacant pot injectar línies de log falses o seqüències d'escapada que manipulen el terminal de l'administrador que el llegeixi. És el mateix tipus de problema que la injecció SQL, aplicat a un altre context.
Sobre TLS. BTCP/1 viatja en clar: qualsevol amb accés al tram de xarxa pot llegir les consultes i els préstecs. Per xifrar-lo, Java ofereix
SSLServerSocketiSSLSocket(paquetjavax.net.ssl), que es fan servir exactament igual queServerSocketiSocketperò xifren el trànsit amb TLS; la feina real són els certificats i els magatzems de claus. La seguretat de xarxa es tracta en profunditat a 12-07; aquí n'hi ha prou de saber que existeix i que un servei amb dades sensibles no ha d'anar en clar.
- Provar-ho amb
telnet i amb nc
telnet i amb ncTerminal 1 — arrenca el servidor:
javac -d classes $(find src -name "*.java")
java -cp classes com.nexussoftware.bibliotech.presentacio.ServidorBiblioTechAppTerminal 2 — connecta't amb telnet i teclegia les peticions:
Sessió real completa (el que tecleges va marcat):
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
200 BIBLIOTECH BTCP/1
CONSULTA 978-0000000001 <-- TECLEJA
200 OK 978-0000000001|Java Eficac|LLIBRE|true
CONSULTA 999 <-- TECLEJA
404 NO TROBAT 999
CONSULTA ../../etc/passwd <-- TECLEJA
400 PETICIO INVALIDA isbn no valid
LLISTA <-- TECLEJA
201 LLISTA 3
978-0000000001|Java Eficac|LLIBRE|true
978-0000000002|Patrons de Disseny|LLIBRE|false
978-0000000003|Refactoritzacio|LLIBRE|true
.
PRESTAR 978-0000000003 Nuria Vidal <-- TECLEJA
200 OK prestec registrat
PRESTAR 978-0000000003 Diego Alonso <-- TECLEJA
409 NO DISPONIBLE 978-0000000003
DEMANA UN CAFE <-- TECLEJA
400 PETICIO INVALIDA ordre desconeguda: DEMANA
SORTIR <-- TECLEJA
221 ADEU
Connection closed by foreign host.Terminal 3 — alhora que la 2, amb nc:
200 BIBLIOTECH BTCP/1
201 LLISTA 3
978-0000000001|Java Eficac|LLIBRE|true
978-0000000002|Patrons de Disseny|LLIBRE|false
978-0000000003|Refactoritzacio|LLIBRE|true
.
221 ADEUI el log del servidor amb les tres connexions simultànies:
INFO: [/127.0.0.1:52440] connectat (1 actives)
INFO: [/127.0.0.1:52441] connectat (2 actives)
INFO: [/127.0.0.1:52441] prestec de 978-0000000003 a Nuria Vidal
INFO: [/127.0.0.1:52442] connectat (3 actives)
INFO: [/127.0.0.1:52442] ha tancat la connexio
INFO: [/127.0.0.1:52442] desconnectat (2 actives)
INFO: [/127.0.0.1:52441] desconnectat (1 actives)
INFO: [/127.0.0.1:52440] desconnectat (0 actives)Compara aquest log amb el del servidor seqüencial. Allà les connexions es processaven una darrere l'altra; aquí se solapen. Aquesta és tota la diferència, i ve d'una línia: pool.execute(...) en lloc d'atendre al mateix fil acceptador.
Proves que convé fer
- Connecta amb
telneti no escriguis res durant 60 segons. El servidor t'expulsa:[client] expulsat per inactivitat (60000 ms). SensesetSoTimeout, aquelltelnetretindria un fil del pool per sempre. - Prova el client de 09-02 contra aquest servidor. Ara que existeixen els dos extrems,
ProvaClientCataleghauria de funcionar de cap a cap. És la primera vegada que el codi de les dues lliçons es troba. - Satura el servidor. Amb
fils=2icua=2a la configuració, obre cinctelnetalhora: els últims reben503 SERVIDOR SATURATi es tanquen. Rebutjar ràpid i amb explicació és molt millor que acceptar i no respondre. - Envia una línia gegant:
python3 -c "print('A'*100000)" | nc localhost 9090. El servidor talla la connexió i registra l'intent, en lloc d'acumular cent mil caràcters en memòria. - Atura el servidor amb Ctrl+C mentre hi ha clients connectats. El shutdown hook es dispara, l'
acceptes desbloqueja, i el log mostra l'aturada en dues fases i les estadístiques finals.
- Aturada ordenada amb shutdown hook
Un servidor no s'atura amb System.exit() ni matant el procés. Un servidor real rep un senyal —SIGTERM de systemd, de Docker o de Kubernetes; SIGINT d'un Ctrl+C— i ha de reaccionar tancant bé.
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
LOG.info("Senyal d'aturada rebut");
servidor.close();
}, "bibliotech-aturada"));Un shutdown hook és un fil que la JVM arrenca quan va a acabar. Regles que cal conèixer:
| Regla | Detall |
|---|---|
S'executa amb SIGTERM i SIGINT |
Ctrl+C, kill, docker stop, systemctl stop |
No s'executa amb SIGKILL (kill -9) |
Res no pot interceptar kill -9. Per això l'estat important es persisteix, no es confia al hook |
| Ha de ser ràpid | El sistema sol donar un termini (10 s a Docker per defecte) abans de passar a SIGKILL |
No ha de cridar System.exit() |
Provoca un interbloqueig: la JVM ja està sortint |
| Diversos hooks s'executen en paral·lel | Sense ordre garantit entre ells |
| Les excepcions que llancin s'ignoren | Registra-les tu, o desapareixen |
A BiblioTech, el hook crida AturadaOrdenada —la classe d'infraestructura del mòdul 8— que ara incorpora també el servidor:
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
LOG.info("Apagant BiblioTech...");
servidor.close(); // 1. deixar d'acceptar i tancar converses
AturadaOrdenada.aturarServeis(); // 2. pools d'avisos, manteniment, reserves
MagatzemPrestecs.bolcar(); // 3. persistir el pendent (modul 7)
LOG.info("BiblioTech aturat correctament");
}, "bibliotech-aturada"));L'ordre és deliberat i no és intercanviable: primer es deixa d'acceptar feina nova, després s'acaba la feina en curs, i al final es persisteix. A l'inrevés es persistiria un estat que encara està canviant.
- Límits del model i què ve després
El model que has construït —un fil de plataforma per connexió, amb pool acotat— és sòlid, senzill d'entendre i correcte per a la immensa majoria de serveis interns. Però té un sostre, i convé saber on és.
El problema C10K
Amb un pool de 16 fils pots atendre 16 converses simultànies. Si les connexions són llargues i majoritàriament inactives —pensa en un xat, o en notificacions, on cada client manté la connexió oberta durant hores i parla dues vegades— el model es trenca: 10.000 clients ociosos requeririen 10.000 fils, és a dir, diversos gigabytes només en piles.
La resposta clàssica: NIO no bloquejant
Java té des de la versió 1.4 una API d'E/S no bloquejant a java.nio.channels:
// E/S no bloquejant: UN fil aten milers de connexions.
Selector selector = Selector.open();
ServerSocketChannel canal = ServerSocketChannel.open();
canal.bind(new InetSocketAddress(9090));
canal.configureBlocking(false); // clau: no bloquejant
canal.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // bloqueja fins que ALGUN canal te activitat
for (SelectionKey clau : selector.selectedKeys()) {
if (clau.isAcceptable()) { /* acceptar */ }
if (clau.isReadable()) { /* llegir el que hi hagi, sense bloquejar */ }
}
}Un Selector permet que un sol fil vigili milers de canals i només treballi sobre els que tenen dades. És el que fan servir Netty, Tomcat NIO i pràcticament tots els servidors d'alt rendiment en Java.
El preu és la complexitat: el codi es torna una màquina d'estats, cada connexió necessita la seva pròpia memòria intermèdia parcial perquè les lectures retornen "el que hi hagi", i depurar-lo és notablement més difícil. No el facis servir llevat que hagis mesurat que el necessites.
La resposta moderna: fils virtuals
Java 21 va canviar el càlcul per complet. Els fils virtuals són fils gestionats per la JVM, no pel sistema operatiu, que costen uns pocs centenars de bytes en lloc d'un megabyte i es poden crear per milions. Quan un es bloqueja en E/S, la JVM el desmunta del fil del sistema i n'hi col·loca un altre.
// Java 21+: un fil VIRTUAL per connexio. Sense pool, sense limit practic.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
while (executant) {
Socket connexio = servidor.accept();
executor.submit(() -> atendreConnexio(connexio));
}
}Aquest codi és el mateix model mental d'un fil per connexió, amb el codi bloquejant i llegible de sempre, però escalant a centenars de milers de connexions. És la resposta de Java al problema C10K sense pagar el preu de NIO.
Els fils virtuals es tracten a 10-06. Per ara queda't amb la idea: el model que has après no queda obsolet, es torna encara més adequat, perquè l'argument en contra d'"un fil per connexió" era el cost del fil, i aquest cost ha desaparegut.
| Model | Connexions que suporta | Complexitat | Quan |
|---|---|---|---|
| Un fil per connexió, sense pool | Centenars | Baixa | Mai en producció: sense límit |
| Pool acotat de fils de plataforma | Centenars | Baixa | Serveis interns. El que has fet |
NIO amb Selector |
Centenars de milers | Alta | Alt rendiment, si s'ha mesurat |
| Fils virtuals (Java 21+) | Centenars de milers | Baixa | El nou per defecte (10-06) |
Errors Comuns i Consells
Atendre la connexió al fil de l'accept. És el servidor seqüencial: un client lent bloqueja tothom, i un de maliciós tomba el servei connectant i callant-se. El fil acceptador només accepta i delega.
Fer servir Executors.newFixedThreadPool() sense pensar. Porta una cua sense límit: sota càrrega, canvies una fallada per una altra. Fes servir ThreadPoolExecutor amb LinkedBlockingQueue acotada i política de rebuig explícita.
Esperar que Thread.interrupt() desbloquegi un accept(). No ho fa. Només tancar el ServerSocket el desbloqueja. I el mateix val per a socket.read(): per això setSoTimeout no és opcional en un servidor.
Cridar setReuseAddress després del bind. No té efecte. Cal crear el ServerSocket sense arguments, configurar-lo i lligar-lo després.
No posar setSoTimeout a les connexions acceptades. Cada client que connecta i es calla es queda amb un fil del pool. Amb un pool de 16, setze clients ociosos —o setze telnet oblidats— deixen el servidor sense capacitat, i al log no apareix cap error.
Fer servir readLine() sense límit de longitud en un servidor exposat. Cent megabytes sense un salt de línia esgoten la memòria. Llegeix acotat.
Compartir el PrintWriter o el BufferedReader entre connexions. Cada conversa té els seus, sempre variables locals del mètode que l'atén. Un camp d'instància del servidor produeix respostes creuades entre clients, i és un bug memorable de diagnosticar.
Encadenar dues operacions segures creient que el resultat és segur. if (disponible) prestar() té una cursa encara que les dues crides siguin atòmiques per separat. L'operació composta ha de ser un mètode del servei.
Deixar que una excepció mati el fil del pool. Un catch (RuntimeException) al voltant de l'atenció de cada connexió és obligatori. Si no, una fallada amb un client concret s'emporta per davant un fil del pool i, amb prou casos, el servidor es queda sense fils.
Retornar o registrar sense sanejar el que va arribar per la xarxa. Un salt de línia injecta una resposta falsa al protocol; una seqüència d'escapada ANSI manipula el terminal de qui llegeixi el log.
Oblidar el flush() a les respostes del servidor. És el bug número u de 09-02, ara des de l'altre costat: el client espera una resposta que continua a la teva memòria intermèdia.
No tancar el socket del client en acabar. Cada socket és un descriptor de fitxer, i hi ha un límit per procés (sovint 1024 per defecte). Un servidor que fuga sockets acaba amb Too many open files, error que apareix hores després d'arrencar i desconcerta tothom. El try (connexio) ho resol.
Consell de diagnòstic. ss -tnp | grep 9090 et mostra en viu totes les connexions amb el seu estat. Moltes en CLOSE_WAIT significa que tu no estàs tancant els teus sockets; moltes en TIME_WAIT és normal després de moltes connexions curtes. És l'equivalent de xarxa a mirar la memòria: et diu si estàs fugant.
Exercicis
Exercici 1: Servidor d'estadístiques de BiblioTech
Afegeix al servidor un segon port (9092) que serveixi un informe d'estat en text pla, pensat perquè l'equip de sistemes de Nexus Software el consulti amb nc o des d'un tauler.
Requisits:
- Un
ServerSocketpropi al 9092, amb el seu propi fil acceptador, compartint el pool amb el servidor principal. - En connectar, sense necessitat d'enviar res, el servidor escriu l'informe i tanca: connexions actives, totals, rebutjades, peticions ateses, fils actius del pool, tasques en cua, temps en marxa en segons i memòria usada.
- Escolta només a
127.0.0.1: les mètriques no han de ser accessibles des de la xarxa. Explica per què en un comentari. - Ha de funcionar amb
nc localhost 9092i ambcurl -s telnet://localhost:9092.
Exercici 2: Limitador de connexions per client
Afegeix al ServidorCataleg un límit de connexions simultànies per adreça IP, perquè un sol equip no pugui ocupar tot el pool.
Requisits:
- Un
ConcurrentHashMap<String, AtomicInteger>que compti connexions actives per IP. - Màxim configurable (per defecte 3) per IP;
127.0.0.1exempta, per no bloquejar-te a tu mateix durant les proves. - En superar-se, respondre
429 MASSA CONNEXIONSi tancar, incrementant un comptador de rebutjos per límit. - El comptador s'ha de decrementar sempre en acabar la connexió, fins i tot si hi va haver excepció. Pensa bé on va aquest decrement.
- Les entrades del mapa s'han d'eliminar quan arriben a zero, o el mapa creix indefinidament. Fes servir una operació atòmica per al parell "decrementar i potser eliminar", i explica per què fer-ho en dos passos tindria una cursa.
Prova'l obrint quatre telnet des de la mateixa màquina amb l'exempció desactivada.
Exercici 3: Prova de càrrega del servidor
Escriu una classe ProvaCarrega que mesuri el comportament del ServidorCataleg sota càrrega fent servir el ClientCataleg de 09-02.
Requisits:
- Paràmetres: nombre de clients concurrents, peticions per client i host/port.
- Cada client s'executa com una tasca en un
ExecutorService, connecta, fa N consultes d'ISBN aleatoris d'una llista, i tanca. - Recollir amb estructures atòmiques: peticions correctes, errors, i latències per calcular mínima, mitjana, mediana, percentil 95 i màxima.
- Fer servir
CountDownLatchperquè tots els clients arrenquin alhora (així es mesura càrrega real, no una rampa) i per esperar que acabin. - Informe final amb peticions per segon i la taula de latències.
- Aturada en dues fases del pool de la prova.
Executa amb 4, 16 i 64 clients contra un servidor de 16 fils i comenta què observes.
Solucions
Solució 1
package com.nexussoftware.bibliotech.xarxa;
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 d'estadistiques de BiblioTech.
*
* SEGURETAT: escolta NOMES a 127.0.0.1. Les metriques d'un servei revelen
* informacio util per a un atacant (carrega, saturacio, memoria, finestres de
* baixa activitat) i solen ser el primer lloc on es filtra alguna cosa sense voler.
* Exposar-les a la xarxa seria una fallada de disseny; si s'han de consultar des de
* fora, es fa per un tunel SSH o darrere d'un proxy autenticat (12-07).
*/
public class ServidorEstadistiques implements AutoCloseable {
private static final Logger LOG = Logger.getLogger(ServidorEstadistiques.class.getName());
private final int port;
private final ServidorCataleg principal;
private final ThreadPoolExecutor poolPrincipal;
private final ExecutorService pool;
private final long instantArrencada = System.currentTimeMillis();
private ServerSocket servidor;
private volatile boolean executant = false;
public ServidorEstadistiques(int port, ServidorCataleg principal,
ThreadPoolExecutor poolPrincipal, ExecutorService pool) {
this.port = port;
this.principal = principal;
this.poolPrincipal = poolPrincipal;
this.pool = pool; // es comparteix el pool: no calen fils propis
}
public void arrencar() throws IOException {
servidor = new ServerSocket();
servidor.setReuseAddress(true);
// El tercer argument del bind LLIGA l'escolta al bucle invertit:
// ni tan sols una fallada de tallafoc exposaria aquest port.
servidor.bind(new InetSocketAddress(
InetAddress.getLoopbackAddress(), port), 10);
executant = true;
// Fil acceptador propi, perque accept() bloqueja.
Thread acceptador = new Thread(this::executar, "bibliotech-stats-acceptador");
acceptador.setDaemon(true); // aquest si que pot ser daemon: no te estat
acceptador.start();
LOG.info(() -> "Servidor d'estadistiques a 127.0.0.1:" + port);
}
private void executar() {
while (executant) {
try {
Socket connexio = servidor.accept();
connexio.setSoTimeout(5_000);
// Es delega al pool compartit: no atenem a l'acceptador.
pool.execute(() -> servirInforme(connexio));
} catch (SocketException e) {
if (!executant) {
LOG.info("Servidor d'estadistiques aturat");
return;
}
LOG.log(Level.SEVERE, "Fallada a accept() d'estadistiques", e);
return;
} catch (IOException e) {
LOG.log(Level.WARNING, "Error acceptant connexio d'estadistiques", e);
}
}
}
/**
* Aquest servei no te protocol: en connectar, escriu i tanca.
* Es el que permet consultar-lo amb un simple "nc localhost 9092".
*/
private void servirInforme(Socket connexio) {
try (connexio) {
BufferedWriter escriptor = new BufferedWriter(
new OutputStreamWriter(connexio.getOutputStream(),
StandardCharsets.UTF_8));
escriptor.write(construirInforme());
escriptor.flush(); // el buidatge de sempre
} catch (IOException e) {
LOG.fine("Fallada servint l'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 segons = (System.currentTimeMillis() - instantArrencada) / 1000;
StringBuilder sb = new StringBuilder();
sb.append("=== BIBLIOTECH - ESTAT DEL SERVIDOR ===\n");
sb.append(String.format("%-28s %s%n", "En marxa des de fa",
formatarDuracio(segons)));
sb.append('\n');
sb.append("--- CONNEXIONS ---\n");
sb.append(String.format("%-28s %d%n", "Actives", principal.connexionsActives()));
sb.append(String.format("%-28s %d%n", "Totals", principal.connexionsTotals()));
sb.append(String.format("%-28s %d%n", "Peticions ateses",
principal.peticionsAteses()));
sb.append('\n');
sb.append("--- POOL DE FILS ---\n");
sb.append(String.format("%-28s %d%n", "Fils actius",
poolPrincipal.getActiveCount()));
sb.append(String.format("%-28s %d%n", "Mida del pool",
poolPrincipal.getPoolSize()));
sb.append(String.format("%-28s %d%n", "Tasques en cua",
poolPrincipal.getQueue().size()));
sb.append(String.format("%-28s %d%n", "Tasques completades",
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", "Nuclis disponibles",
rt.availableProcessors()));
return sb.toString();
}
private String formatarDuracio(long segons) {
// Sense java.time (aixo es 10-05): aritmetica simple sobre enters.
long h = segons / 3600;
long m = (segons % 3600) / 60;
long s = segons % 60;
return String.format("%dh %02dm %02ds", h, m, s);
}
@Override
public void close() {
executant = false;
try {
if (servidor != null) {
servidor.close(); // desbloqueja l'accept()
}
} catch (IOException ignorada) {
// Tancant ja no hi ha res a salvar.
}
}
}Prova:
=== BIBLIOTECH - ESTAT DEL SERVIDOR ===
En marxa des de fa 0h 04m 17s
--- CONNEXIONS ---
Actives 2
Totals 47
Peticions ateses 183
--- POOL DE FILS ---
Fils actius 2
Mida del pool 16
Tasques en cua 0
Tasques completades 45
--- MEMORIA ---
Usada 28 MB de 4096 MB (0,7%)
Nuclis disponibles 8Comentaris. Tres decisions de disseny. La primera i més important: lligar l'escolta a InetAddress.getLoopbackAddress() al bind. No és el mateix que confiar en un tallafoc: el socket ni tan sols existeix a les altres interfícies, així que un error de configuració de xarxa no el pot exposar. La segona: es comparteix el pool del servidor principal en lloc de crear-ne un altre; el servei d'estadístiques atén connexions instantànies i esporàdiques, i donar-li fils propis seria malbaratar memòria. La tercera: aquest servei no té protocol, escriu i tanca, i això és precisament el que permet consultar-lo amb nc sense res més. Fixa't a més que el fil acceptador d'estadístiques sí que és daemon, al contrari que els del pool principal: no té estat a perdre, així que no hi ha problema que la JVM el mati en sortir.
Solució 2
package com.nexussoftware.bibliotech.xarxa;
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 connexions simultanies per adreca IP.
*
* Evita que un sol equip (per error o a proposit) ocupi tot el pool
* de fils del servidor i deixi sense servei els altres llocs de treball.
*/
public class LimitadorPerIp {
private static final Logger LOG = Logger.getLogger(LimitadorPerIp.class.getName());
private final int maximPerIp;
private final boolean eximirBucleInvertit;
/** Compta connexions actives per IP. Concurrent: la toquen tots els fils. */
private final ConcurrentHashMap<String, AtomicInteger> perIp = new ConcurrentHashMap<>();
private final AtomicLong rebutjadesPerLimit = new AtomicLong();
public LimitadorPerIp(int maximPerIp, boolean eximirBucleInvertit) {
this.maximPerIp = maximPerIp;
this.eximirBucleInvertit = eximirBucleInvertit;
}
/**
* Intenta reservar una placa per a aquesta IP.
* Retorna true si s'admet; false si s'ha assolit el limit.
*/
public boolean admetre(Socket connexio) {
String ip = ipDe(connexio);
if (eximirBucleInvertit && esBucleInvertit(ip)) {
// Sense l'exempcio, provar el servidor amb diversos telnet des de
// la propia maquina seria impossible.
return true;
}
// computeIfAbsent es atomic: si dos fils hi arriben alhora amb la
// mateixa IP, nomes un crea l'AtomicInteger i tots dos fan servir el mateix.
AtomicInteger comptador = perIp.computeIfAbsent(ip, clau -> new AtomicInteger());
// Bucle de comparar-i-intercanviar: la forma atomica de dir
// "incrementa NOMES SI el valor actual es menor que el maxim".
// Un "if (get() < max) incrementAndGet()" tindria una cursa:
// dos fils podrien passar l'if alhora i superar el limit.
while (true) {
int actual = comptador.get();
if (actual >= maximPerIp) {
rebutjadesPerLimit.incrementAndGet();
LOG.warning("Limit de " + maximPerIp
+ " connexions assolit per a " + ip);
return false;
}
if (comptador.compareAndSet(actual, actual + 1)) {
return true; // reserva aconseguida
}
// compareAndSet ha fallat: un altre fil ha canviat el valor entremig.
// Es reintenta amb el valor nou. Aixo es 08-06.
}
}
/**
* Allibera la placa. S'HA de cridar sempre, des d'un finally.
*/
public void alliberar(Socket connexio) {
String ip = ipDe(connexio);
if (eximirBucleInvertit && esBucleInvertit(ip)) {
return; // no s'ha reservat, no s'allibera
}
// computeIfPresent fa ATOMICAMENT el parell "decrementar i, si
// arriba a zero, eliminar l'entrada". Retornar null des de la
// funcio elimina la clau del mapa.
//
// Fer-ho en dos passos tindria una cursa classica:
// if (comptador.decrementAndGet() == 0) perIp.remove(ip);
// Entre el decrement i el remove, un altre fil pot incrementar
// aquest mateix comptador a 1 (una connexio nova de la mateixa IP), i
// el remove eliminaria un comptador que ja val 1. La connexio
// nova quedaria comptabilitzada en un objecte orfe i el limit
// deixaria d'aplicar-se a aquella IP.
perIp.computeIfPresent(ip, (clau, comptador) -> {
int queden = comptador.decrementAndGet();
return queden <= 0 ? null : comptador; // null elimina l'entrada
});
}
private String ipDe(Socket connexio) {
return connexio.getInetAddress().getHostAddress();
}
private boolean esBucleInvertit(String ip) {
return ip.equals("127.0.0.1") || ip.equals("0:0:0:0:0:0:0:1") || ip.equals("::1");
}
public long rebutjadesPerLimit() {
return rebutjadesPerLimit.get();
}
public int ipsConnectades() {
return perIp.size();
}
}Integració al bucle d'acceptació de ServidorCataleg:
// A executar(), despres d'acceptar:
Socket acceptada = servidor.accept();
acceptada.setSoTimeout(limitInactivitatMs);
acceptada.setTcpNoDelay(true);
if (!limitador.admetre(acceptada)) {
rebutjar(acceptada, "429 MASSA CONNEXIONS");
continue; // NO s'ha arribat a delegar al pool: no hi ha res a alliberar
}
try {
pool.execute(() -> atendreConnexio(acceptada));
} catch (RejectedExecutionException e) {
// COMPTE: la placa JA estava reservada. Si no s'allibera aqui,
// es fuga una placa a cada rebuig del pool i la IP acaba
// bloquejada per sempre.
limitador.alliberar(acceptada);
rebutjar(acceptada, "503 SERVIDOR SATURAT");
}I a atendreConnexio, el decrement va al finally que ja existia:
} finally {
limitador.alliberar(connexio); // SEMPRE, passi el que passi
int queden = connexionsActives.decrementAndGet();
LOG.info(() -> "[" + client + "] desconnectat (" + queden + " actives)");
}Comentaris. Aquest exercici concentra tres lliçons de concurrència del mòdul 8 aplicades a un problema real.
El bucle de comparar-i-intercanviar és la part que més es falla. El codi intuïtiu, if (comptador.get() < maxim) comptador.incrementAndGet(), té una cursa evident: amb el límit a 3 i el comptador a 2, dos fils poden llegir 2, tots dos passar l'if i tots dos incrementar, deixant-lo a 4. El bucle amb compareAndSet incrementa només si el valor no ha canviat des que el vam llegir, i reintenta si va canviar. És exactament el patró de 08-06.
El computeIfPresent per decrementar i eliminar resol una cursa més subtil, explicada al comentari del codi: el parell "decrementar" i "eliminar si és zero" ha de ser una sola operació atòmica, o una connexió nova de la mateixa IP pot colar-se entre les dues i quedar comptabilitzada en un comptador que s'acaba de treure del mapa. I eliminar l'entrada no és un detall estètic: sense això, perIp acumula una entrada per cada IP que s'hagi connectat alguna vegada, i això és una fuga de memòria lenta però segura.
L'alliberar al catch de RejectedExecutionException és la fallada que més costa de trobar. La placa es reserva abans de delegar al pool; si el pool rebutja, aquella placa queda reservada per sempre perquè mai no s'executarà el finally d'atendreConnexio. Basten tres saturacions perquè un lloc de treball quedi bloquejat permanentment, i el símptoma —"des de l'ordinador de la Marta no es pot entrar, però des del meu sí"— és dels que porten un dia sencer de depuració.
Solució 3
package com.nexussoftware.bibliotech.xarxa;
import com.nexussoftware.bibliotech.excepcio.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;
/**
* Prova de carrega del ServidorCataleg fent servir el ClientCataleg de 09-02.
* Mesura latencies, taxa d'error i peticions per segon.
*/
public class ProvaCarrega {
private static final String[] ISBNS = {
"978-0000000001",
"978-0000000002",
"978-0000000003",
"000-0000000000" // inexistent a proposit: ha de donar 404, no error
};
private final String host;
private final int port;
private final AtomicInteger correctes = new AtomicInteger();
private final AtomicInteger errors = new AtomicInteger();
private final AtomicInteger connexionsFallides = new AtomicInteger();
private final AtomicLong indexLatencies = new AtomicLong();
/** Latencies en microsegons. Array preassignat: sense sincronitzacio en escriure. */
private long[] latencies;
public ProvaCarrega(String host, int port) {
this.host = host;
this.port = port;
}
public void executar(int clients, int peticionsPerClient) throws InterruptedException {
latencies = new long[clients * peticionsPerClient];
// Pool per als clients de la prova, amb fils anomenats.
ExecutorService pool = Executors.newFixedThreadPool(clients, r -> {
Thread f = new Thread(r);
f.setName("carrega-" + f.getId());
return f;
});
// Dos latches: un perque TOTS arrenquin alhora (carrega real,
// no una rampa), i un altre per esperar que TOTS acabin (08-05).
CountDownLatch sortida = new CountDownLatch(1);
CountDownLatch arribada = new CountDownLatch(clients);
for (int i = 0; i < clients; i++) {
final int id = i;
pool.execute(() -> {
try {
sortida.await(); // tots esperen aqui el tret de sortida
treballar(id, peticionsPerClient);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
arribada.countDown();
}
});
}
System.out.printf("Llancant %d clients x %d peticions = %d peticions%n%n",
clients, peticionsPerClient, clients * peticionsPerClient);
long t0 = System.nanoTime();
sortida.countDown(); // ja!
arribada.await(); // esperem tothom
long duracioNs = System.nanoTime() - t0;
// Aturada en dues fases del pool de la prova (08-05).
pool.shutdown();
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
pool.shutdownNow();
}
informe(duracioNs);
}
private void treballar(int id, int peticions) {
// Cada client obre la seva propia connexio: ClientCataleg NO es
// segur per a diversos fils, i a mes aixi mesurem connexions reals.
try (ClientCataleg client = new ClientCataleg(host, port)) {
client.connectar();
for (int i = 0; i < peticions; i++) {
String isbn = ISBNS[(id + i) % ISBNS.length];
long t0 = System.nanoTime();
try {
client.consultar(isbn); // null (404) tambe es exit
long us = (System.nanoTime() - t0) / 1000;
// Cada fil escriu a la seva propia posicio de l'array:
// sense collisions, sense sincronitzacio. L'index es
// reparteix amb un comptador atomic.
int pos = (int) indexLatencies.getAndIncrement();
if (pos < latencies.length) {
latencies[pos] = us;
}
correctes.incrementAndGet();
} catch (BiblioTechException e) {
errors.incrementAndGet();
}
}
} catch (BiblioTechException e) {
// No s'ha pogut ni connectar: es compta a part, perque significa
// una altra cosa (saturacio) que un error en una peticio.
connexionsFallides.incrementAndGet();
}
}
private void informe(long duracioNs) {
int fetes = (int) Math.min(indexLatencies.get(), latencies.length);
if (fetes == 0) {
System.out.println("No s'ha completat cap peticio.");
System.out.println("Connexions fallides: " + connexionsFallides.get());
return;
}
long[] ordenades = Arrays.copyOf(latencies, fetes);
Arrays.sort(ordenades); // 05-09: ordenacio per als percentils
long suma = 0;
for (long v : ordenades) {
suma += v;
}
double segons = duracioNs / 1_000_000_000.0;
double perSegon = correctes.get() / segons;
System.out.println("=== RESULTAT DE LA PROVA DE CARREGA ===");
System.out.printf("%-26s %.2f s%n", "Duracio total", segons);
System.out.printf("%-26s %d%n", "Peticions correctes", correctes.get());
System.out.printf("%-26s %d%n", "Peticions amb error", errors.get());
System.out.printf("%-26s %d%n", "Connexions fallides", connexionsFallides.get());
System.out.printf("%-26s %.0f%n", "Peticions per segon", perSegon);
System.out.println();
System.out.println("--- LATENCIES (ms) ---");
System.out.printf("%-26s %.3f%n", "Minima", ordenades[0] / 1000.0);
System.out.printf("%-26s %.3f%n", "Mitjana", (suma / (double) fetes) / 1000.0);
System.out.printf("%-26s %.3f%n", "Mediana (p50)", percentil(ordenades, 50) / 1000.0);
System.out.printf("%-26s %.3f%n", "p95", percentil(ordenades, 95) / 1000.0);
System.out.printf("%-26s %.3f%n", "p99", percentil(ordenades, 99) / 1000.0);
System.out.printf("%-26s %.3f%n", "Maxima",
ordenades[ordenades.length - 1] / 1000.0);
}
private long percentil(long[] ordenades, int p) {
// Index del percentil sobre l'array ja ordenat.
int index = (int) Math.ceil(p / 100.0 * ordenades.length) - 1;
return ordenades[Math.max(0, Math.min(index, ordenades.length - 1))];
}
public static void main(String[] args) throws InterruptedException {
String host = args.length > 0 ? args[0] : "localhost";
int port = args.length > 1 ? Integer.parseInt(args[1]) : 9090;
for (int clients : new int[]{4, 16, 64}) {
System.out.println("############ " + clients + " CLIENTS ############");
new ProvaCarrega(host, port).executar(clients, 200);
System.out.println();
Thread.sleep(1000); // marge perque el servidor es recuperi
}
}
}Sortida contra un servidor de 16 fils i cua de 100:
############ 4 CLIENTS ############
Llancant 4 clients x 200 peticions = 800 peticions
=== RESULTAT DE LA PROVA DE CARREGA ===
Duracio total 0,42 s
Peticions correctes 800
Peticions amb error 0
Connexions fallides 0
Peticions per segon 1897
--- LATENCIES (ms) ---
Minima 0,118
Mitjana 0,204
Mediana (p50) 0,171
p95 0,392
p99 0,884
Maxima 4,113
############ 16 CLIENTS ############
Peticions per segon 6104
Mediana (p50) 0,224
p95 0,668
p99 1,942
Maxima 11,208
############ 64 CLIENTS ############
Connexions fallides 0
Peticions per segon 6438
Mediana (p50) 1,102
p95 4,873
p99 12,441
Maxima 58,306Comentaris. Els números expliquen una història molt clara, i és la mateixa que veuràs en qualsevol servei real.
De 4 a 16 clients, el rendiment es multiplica per 3,2 (1897 → 6104 peticions/s) i la latència mediana amb prou feines es mou. El servidor tenia capacitat de sobres i ara l'està fent servir: el pool de 16 fils està aprofitat.
De 16 a 64 clients, el rendiment s'estanca (6104 → 6438, un 5 % més) però la latència mediana es multiplica per cinc i el p99 per sis. Aquest és el comportament canònic d'un sistema saturat: passat el punt de saturació, més càrrega no dóna més feina feta, només més cua. Els 64 clients es reparteixen els mateixos 16 fils, així que cadascun espera el seu torn.
Fixa't en el p99 davant de la mediana. Amb 64 clients, la meitat de les peticions triguen menys d'1,1 ms, però una de cada cent triga més de 12 ms, i la pitjor 58 ms. La mitjana menteix i la mediana no n'hi ha prou: en un servei real, el que els usuaris perceben com a "va lent" és el p95 i el p99, no la mitjana. Mesurar només la mitjana és l'error clàssic de les proves de rendiment.
I la màxima de 4,1 ms amb només 4 clients és l'escalfament de la JVM: les primeres peticions carreguen classes i executen codi encara interpretat, abans que el compilador JIT faci la seva feina. Qualsevol prova de rendiment seriosa descarta un període inicial d'escalfament; si no, aquests valors contaminen el màxim i el p99.
Conclusió
Has construït l'altre extrem, i amb ell BiblioTech ha deixat d'estar sola de veritat: hi ha un servidor en una màquina de Nexus Software al qual la Marta, en Diego i la Nuria es connecten alhora des dels seus llocs.
Saps distingir les dues classes que hi intervenen i per què són diferents: ServerSocket espera, Socket conversa. N'hi ha un del primer per servei i un del segon per client connectat, i al ServerSocket no s'hi llegeix ni s'hi escriu: la seva única feina és fabricar connexions. Coneixes les tres decisions de la seva creació —el port, el backlog que amorteix ràfegues però no arregla un servidor lent, i l'adreça d'escolta, que és una decisió de seguretat: 0.0.0.0 exposa a la xarxa i 127.0.0.1 només a la pròpia màquina—. I saps que el port 0 fa que el sistema te n'assigni un de lliure, la tècnica estàndard en proves automatitzades.
Entens accept(): bloqueja, retorna un Socket ja connectat —la salutació de tres vies la va completar el sistema operatiu abans, i per això un client pot estar connectat i esperant a la cua encara que el teu codi estigui ocupat—, admet setSoTimeout i, sobretot, no respon a Thread.interrupt(). Tancar el ServerSocket és l'única cosa que el desbloqueja, i aquesta és la peça que governa tota l'aturada.
Has resolt el BindException: Address already in use, que trobaràs segur: si és un altre procés, ss -tlnp el delata; si és TIME_WAIT, la solució és setReuseAddress(true) abans del bind, cosa que obliga al patró de tres passos: crear sense lligar, configurar, lligar.
Has vist en directe, amb tres terminals de telnet, per què un servidor seqüencial és inacceptable: el segon client connecta però no rep ni la salutació fins que el primer acaba; i pitjor, un sol client que connecti i es calli deixa el servei caigut. Has descartat també l'"un fil per connexió sense límit" pel que realment és: una vulnerabilitat de denegació de servei, perquè deu mil connexions creen deu mil fils i tomben la JVM.
I has aplicat la solució del mòdul 8 al problema per al qual es va inventar: un ThreadPoolExecutor acotat, amb ThreadFactory que dóna noms llegibles als fils perquè els logs i els bolcats serveixin d'alguna cosa, cua acotada —perquè newFixedThreadPool en porta una sense límit i això només canvia un OutOfMemoryError per un altre— i política de rebuig explícita, amb AbortPolicy i un 503 SERVIDOR SATURAT de cortesia, perquè rebutjar ràpid i amb explicació sempre és millor que acceptar i no respondre. El fil acceptador només accepta i delega; tota la conversa passa al pool.
Domines l'aturada ordenada i les seves tres peces, cap prescindible: el marcador volatile que distingeix el tancament intencionat d'una fallada real —sense ell, cada aturada neta deixa un SEVERE al log—, el tancament del ServerSocket que desbloqueja l'accept, i l'aturada en dues fases del pool amb shutdown, awaitTermination, shutdownNow i restauració del marcador d'interrupció. Amb el detall que ho lliga tot: shutdownNow() tampoc no desbloqueja un socket.read(), i per això el setSoTimeout de cada connexió no és un adorn defensiu sinó el mecanisme que fa que l'aturada funcioni.
Has comprovat que l'estat compartit ja estava resolt: CatalegConcurrent amb el seu ConcurrentHashMap, EstadistiquesBiblioTech amb els seus LongAdder, RegistrePrestecsSegur amb el seu ReadWriteLock. No va caldre tocar ni una línia. L'única cosa que continua exigint cura és la de sempre: les operacions compostes no són atòmiques per encadenar operacions atòmiques, i per això prestar és un únic mètode del servei i no un if seguit d'una crida.
I saps que tot el que arriba per un socket és entrada no fiable, encara que vingui de la xarxa interna. El teu servidor ho tracta com a tal: lectura de línia acotada en lloc d'un readLine() sense límit, sostre de peticions per connexió, setSoTimeout que expulsa els inactius, pool i cua acotats, validació per llista blanca —mai llista negra—, sanejament de tot el que torna al client o al log per evitar la injecció de salts de línia i d'escapades de terminal, i un catch (RuntimeException) per connexió perquè una fallada amb un client no s'emporti per davant un fil del pool.
BiblioTech guanya en aquesta lliçó ServidorCataleg —el servidor BTCP/1 complet, multiclient, validat, mesurat i amb aturada ordenada—, ServidorBiblioTechApp amb el seu shutdown hook, i les classes dels exercicis: ServidorEstadistiques lligat al bucle invertit, LimitadorPerIp amb el seu bucle de comparar-i-intercanviar, i ProvaCarrega, que t'ha ensenyat amb números el comportament d'un sistema saturat: passat el punt de saturació, més càrrega no dóna més feina feta, només més cua, i la mitjana menteix mentre el p99 diu la veritat.
Saps a més on és el sostre d'aquest model i què hi ha més enllà: NIO amb Selector per al problema C10K, potent i notablement més complex; i els fils virtuals de Java 21, que no invaliden el que has après sinó que ho fan encara més adequat, perquè l'únic argument contra "un fil per connexió" era el cost del fil i aquest cost ha desaparegut (10-06). I saps que BTCP/1 viatja en clar, que SSLServerSocket és la resposta, i que la seguretat de xarxa es tracta a fons a 12-07.
A la lliçó següent, DatagramSocket i DatagramPacket, canvia el transport. Deixaràs anar totes les garanties de TCP: sense connexió, sense lliurament assegurat, sense ordre, sense control de flux — i descobriràs per què això de vegades és exactament el que vols. Veuràs el DatagramPacket com un sobre amb dades, longitud, adreça i port, i el DatagramSocket com una bústia; els dos errors clàssics que comet tothom amb ells; què són l'MTU i la fragmentació, i per què 512 bytes és el prudent; com tolerar pèrdues, duplicats i desordre, i com construir fiabilitat a mà quan cal; i la difusió i la multidifusió, que TCP simplement no pot fer. Amb dues aplicacions per a BiblioTech que TCP no permet: un servei de descobriment en què els llocs de treball criden «ON ÉS BIBLIOTECH?» a tota la xarxa local i el servidor respon amb la seva adreça —de manera que ja no caldrà configurar la IP a cada lloc— i un emissor de telemetria que envia el nombre de préstecs actius cada pocs segons sense bloquejar i sense importar si algun paquet es perd pel camí.
Curs de Programació en Java
Mòdul 1: Introducció a Java
- Introducció a Java
- Configuració de l'entorn de desenvolupament
- Sintaxi i estructura bàsica
- Variables i tipus de dades
- Operadors
- Entrada i sortida per consola
- El teu primer programa complet: BiblioTech
Mòdul 2: Flux de control
- Sentències condicionals
- Bucles
- Sentències switch
- Break i continue
- Depuració i traces d'execució
- Projecte: menú interactiu de BiblioTech
Mòdul 3: Programació orientada a objectes
- Introducció a la POO
- Classes i objectes
- Mètodes
- Constructors
- Herència
- Polimorfisme
- Encapsulament
- Abstracció
- La classe Object: equals, hashCode i toString
Mòdul 4: Programació orientada a objectes avançada
- Interfícies
- Classes abstractes
- Classes internes
- Classes anònimes
- Expressions lambda
- Interfícies funcionals i referències a mètodes
- Enumeracions i registres
Mòdul 5: Estructures de dades i col·leccions
- Arrays
- El framework de col·leccions
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cua i Deque
- Pila
- Ordenació i cerca en col·leccions
Mòdul 6: Gestió d'excepcions
- Introducció a les excepcions
- Bloc try-catch
- Throw i throws
- Excepcions personalitzades
- Bloc finally
- Try-with-resources i AutoCloseable
- Estratègies de gestió d'errors i logging
Mòdul 7: Entrada/sortida de fitxers
- Lectura de fitxers
- Escriptura de fitxers
- Fluxos de fitxers
- BufferedReader i BufferedWriter
- Serialització
- L'API NIO.2: Path i Files
- Formats d'intercanvi: CSV i Properties
Mòdul 8: Multifil i concurrència
- Introducció al multifil
- Creació de fils
- Cicle de vida d'un fil
- Sincronització
- Utilitats de concurrència
- Col·leccions concurrents i variables atòmiques
- Tasques asíncrones amb CompletableFuture
Mòdul 9: Xarxes
- Introducció a les xarxes
- Sockets
- ServerSocket
- DatagramSocket i DatagramPacket
- URL i HttpURLConnection
- El client HTTP modern
Mòdul 10: Temes avançats
- Genèrics
- Anotacions
- Reflexió
- Característiques de Java 8: Streams i Optional
- Dates i hores amb java.time
- Java 9 i més enllà
- Memòria, recol·lecció de brossa i rendiment
Mòdul 11: Frameworks i llibreries de Java
- Introducció als frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Proves avançades amb Mockito
- Llibreries essencials de l'ecosistema
