Java sockets I/O: il blocco non-blocking e asincrona

Foto di Evi Radauscher su Unsplash

Quando si descrivono I/O, i termini non-blocking e asincrona sono spesso usati in modo intercambiabile, ma c’è una notevole differenza tra di loro. In questo articolo sono descritte le differenze teoriche e pratiche tra le operazioni di I/O socket non bloccanti e asincroni in Java.

I socket sono endpoint per eseguire comunicazioni bidirezionali tramite protocolli TCP e UDP. Le API Java sockets sono adattatori per le corrispondenti funzionalità dei sistemi operativi. Comunicazione socket nei sistemi operativi POSIX-compliant (Unix, Linux, Mac OS X, BSD, Solaris,AIX, ecc.) viene eseguita da Berkeley sockets. Sockets comunicazione in Windows viene eseguita da Winsock che si basa anche su prese Berkeley con funzionalità aggiuntive per rispettare il modello di programmazione di Windows.

Le definizioni POSIX

In questo articolo vengono utilizzate definizioni semplificate dalla specifica POSIX.

Thread bloccato-un thread che è in attesa di qualche condizione prima di poter continuare l’esecuzione.

Blocco-una proprietà di un socket che fa sì che le chiamate ad esso attendano l’azione richiesta da eseguire prima di tornare.

Non-blocking — una proprietà di un socket che fa sì che le chiamate ad esso ritornino senza ritardo, quando viene rilevato che l’azione richiesta non può essere completata senza un ritardo sconosciuto.

Operazione I/O sincrona: un’operazione I/O che causa il blocco del thread richiedente fino al completamento dell’operazione I / O.

Operazione I/O asincrona: un’operazione I/O che di per sé non causa il blocco del thread richiedente; ciò implica che il thread e l’operazione I/O potrebbero essere in esecuzione contemporaneamente.

Quindi, secondo le specifiche POSIX, la differenza tra i termini non bloccanti e asincroni è ovvia:

  • non-blocking — una proprietà di un socket che causa le chiamate a restituire senza ritardo
  • I/O asincrono — una proprietà di un’operazione di I/O (lettura o scrittura), che viene eseguito in concomitanza con la richiesta di thread

I/O modelli

Il seguito di I/O modelli sono il tipo più comune di POSIX-compliant sistemi operativi:

  • blocco di I/O modello
  • non-blocking I/O modello
  • I/O multiplexing modello
  • segnale-driven I/O modello
  • I/O asincrono modello

Blocco di I/O modello

blocco di I/O modello, l’applicazione rende un blocco di chiamata di sistema fino a quando i dati vengono ricevuti dal kernel e sono copiati dal kernel di spazio in spazio utente.

blocco I / O modello

 blocco I/O modello

Pro:

  • Il modello I/O più semplice da implementare

Contro:

  • L’applicazione è bloccata

Modello I/O non bloccante

Nel modello I/O non bloccante l’applicazione effettua una chiamata di sistema che restituisce immediatamente una delle due risposte:

  • se l’operazione di I/O può essere completata immediatamente, i dati vengono restituiti
  • se l’operazione di I/O non può essere completata immediatamente, un codice di errore che indica che l’operazione di I/O per il blocco o il dispositivo è temporaneamente non disponibile

Per completare l’operazione di I/O, l’applicazione deve busy-wait (fare ripetere le chiamate di sistema) fino al completamento.

modello I / O non bloccante

 modello I / O non bloccante

Pro:

  • L’applicazione non è bloccata

Contro:

  • L’applicazione deve occupato-attendere fino al completamento, che potrebbe causare molti user-kernel contesto
  • Questo modello può introdurre latenza di I/O perché non ci può essere un divario tra la disponibilità di dati nel kernel e la lettura dei dati dall’applicazione

I/O multiplexing modello

i/O multiplexing modello (noto anche come non-blocking I/O modello con il blocco delle notifiche), l’applicazione consente di selezionare un blocco di chiamata di sistema per iniziare a monitorare le attività su molti descrittori. Per ogni descrittore, è possibile richiedere la notifica della sua disponibilità per determinate operazioni di I/O (connessione, lettura o scrittura, occorrenza di errori, ecc.). Quando la chiamata di sistema select restituisce che almeno un descrittore è pronto, l’applicazione effettua una chiamata non bloccante e copia i dati dallo spazio del kernel nello spazio utente.

I/O multiplexing modello

I/O multiplexing modello

Pro:

  • È possibile eseguire operazioni di I/O più descrittori in un thread

Contro:

  • L’applicazione è ancora bloccato nel selezionare il sistema di chiamata
  • Non tutti i sistemi operativi supportano questo modello efficiente

Segnale-driven I/O modello

Nel segnale-driven I/O modello, l’applicazione effettua una chiamata non bloccante e registra un gestore di segnale. Quando un descrittore è pronto per un’operazione di I/O, viene generato un segnale per l’applicazione. Quindi il gestore del segnale copia i dati dallo spazio del kernel nello spazio utente.

segnale-driven I/O modello

segnale-driven I/O modello

Pro:

  • L’applicazione non è bloccato
  • Segnali in grado di fornire buone prestazioni

Contro:

  • Non tutti i sistemi operativi supportano segnali

I/O Asincrono modello

l’I/O asincrono modello (conosciuto anche come overlapped I/O modello) l’applicazione rende la non chiamata di blocco e inizia un’operazione in background nel kernel. Quando l’operazione è completata (i dati vengono ricevuti nel kernel e copiati dallo spazio del kernel nello spazio utente), viene generata una callback di completamento per completare l’operazione di I/O.

Una differenza tra il modello di I/O asincrono e il modello di I/O guidato dal segnale è che con I/O guidato dal segnale, il kernel dice all’applicazione quando un’operazione di I/O può essere avviata, ma con il modello di I/O asincrono, il kernel dice all’applicazione quando un’operazione di I/O è completata.

I/O asincrono modello

I/O asincrono modello

Pro:

  • L’applicazione non è bloccato
  • Questo modello è in grado di fornire le migliori prestazioni

Contro:

  • La parte più complicata di I/O modello per implementare
  • Non tutti i sistemi operativi supportano questo modello efficiente

Java I/O Api

Java IO API si basa su flussi (InputStream, OutputStream) che rappresentano il blocco, la uno-direzionale del flusso di dati.

Java NIO API

Java NIO API si basa sul canale, Buffer, classi di selezione, che sono adattatori per operazioni di I/O di basso livello dei sistemi operativi.

La classe Channel rappresenta una connessione a un’entità (dispositivo hardware, file, socket, componente software, ecc.) in grado di eseguire operazioni di I/O (lettura o scrittura).

Rispetto ai flussi unidirezionali, i canali sono bidirezionali.

La classe Buffer è un contenitore di dati a dimensione fissa con metodi aggiuntivi per leggere e scrivere dati. Tutti i dati del canale vengono gestiti tramite Buffer ma mai direttamente: tutti i dati inviati a un canale vengono scritti in un buffer, tutti i dati ricevuti da un canale vengono letti in un Buffer.

Rispetto ai flussi, che sono orientati ai byte, i canali sono orientati ai blocchi. L’I / O orientato ai byte è più semplice ma per alcune entità I/O può essere piuttosto lento. L’I / O orientato ai blocchi può essere molto più veloce ma è più complicato.

La classe Selector consente la sottoscrizione di eventi da molti oggetti SelectableChannel registrati in una singola chiamata. Quando gli eventi arrivano, un oggetto Selettore li invia ai gestori di eventi corrispondenti.

API Java NIO2

L’API Java NIO2 si basa su canali asincroni (AsynchronousServerSocketChannel, AsynchronousSocketChannel, ecc.) che supportano operazioni di I/O asincrone (connessione, lettura o scrittura, gestione degli errori).

I canali asincroni forniscono due meccanismi per controllare le operazioni di I/O asincroni. Il primo meccanismo è restituendo un java.util.simultaneo.Oggetto futuro, che modella un’operazione in sospeso e può essere utilizzato per interrogare lo stato e ottenere il risultato. Il secondo meccanismo è passando all’operazione un java.nio.canale.Oggetto CompletionHandler, che definisce i metodi del gestore che vengono eseguiti dopo che l’operazione è stata completata o non riuscita. Le API fornite per entrambi i meccanismi sono equivalenti.

Canali asincroni forniscono un modo standard di eseguire operazioni asincrone piattaforma-indipendentemente. Tuttavia, la quantità che Java sockets API può sfruttare le funzionalità asincrone native di un sistema operativo, dipenderà dal supporto per quella piattaforma.

Socket echo server

La maggior parte dei modelli di I/O sopra menzionati sono implementati qui nei server echo e nei client con API Java sockets. I server e i client echo funzionano con il seguente algoritmo:

  1. un server in ascolto su un socket registrati porta TCP 7000
  2. un client si connette da un socket sulla porta TCP dinamica per il socket del server
  3. il client legge una stringa di input dalla console e invia il byte dalla sua presa per il socket del server
  4. il server riceve il byte dalla sua presa e li invia al client socket
  5. il client riceve il byte dalla sua presa e scrive l’eco di una stringa su console
  6. quando il client riceve lo stesso numero di byte che ha inviato, si disconnette dal server
  7. quando il server riceve una stringa speciale, smette di ascoltare

La conversione tra stringhe e byte qui viene eseguita esplicitamente nella codifica UTF-8.

Inoltre vengono forniti solo codici semplificati per i server echo. Il collegamento ai codici completi per i server e i client echo è fornito nella conclusione.

Blocco IO echo server

Nell’esempio seguente, il modello di blocco I/O viene implementato in un server echo con API Java IO.

Il ServerSocket.accetta i blocchi del metodo finché non viene accettata una connessione. L’InputStream.leggere i blocchi del metodo fino a quando i dati di input non sono disponibili o un client non viene disconnesso. L’OutputStream.scrivi i blocchi del metodo finché non vengono scritti tutti i dati di output.

public class IoEchoServer { public static void main(String args) throws IOException {
ServerSocket serverSocket = new ServerSocket(7000); while (active) {
Socket socket = serverSocket.accept(); // blocking InputStream is = socket.getInputStream();
OutputStream os = socket.getOutputStream(); int read;
byte bytes = new byte;
while ((read = is.read(bytes)) != -1) { // blocking
os.write(bytes, 0, read); // blocking
} socket.close();
} serverSocket.close();
}
}

Blocco di NIO echo server

Nell’esempio seguente, il modello di blocco I/O viene implementato in un server echo con API Java NIO.

Gli oggetti ServerSocketChannel e SocketChannel sono configurati di default nella modalità di blocco. Il ServerSocketChannel.accept method blocca e restituisce un oggetto SocketChannel quando viene accettata una connessione. Il ServerSocket.leggere i blocchi del metodo fino a quando i dati di input non sono disponibili o un client non viene disconnesso. Il ServerSocket.scrivi i blocchi del metodo finché non vengono scritti tutti i dati di output.

public class NioBlockingEchoServer { public static void main(String args) throws IOException {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress("localhost", 7000)); while (active) {
SocketChannel socketChannel = serverSocketChannel.accept(); // blocking ByteBuffer buffer = ByteBuffer.allocate(1024);
while (true) {
buffer.clear();
int read = socketChannel.read(buffer); // blocking
if (read < 0) {
break;
} buffer.flip();
socketChannel.write(buffer); // blocking
} socketChannel.close();
} serverSocketChannel.close();
}
}

NIO echo server non bloccante

Nell’esempio seguente, il modello di I/O non bloccante viene implementato in un server echo con API Java NIO.

Gli oggetti ServerSocketChannel e SocketChannel sono configurati esplicitamente nella modalità non bloccante. Il ServerSocketChannel.il metodo accept non blocca e restituisce null se non viene ancora accettata alcuna connessione o un oggetto SocketChannel altrimenti. Il ServerSocket.read non blocca e restituisce 0 se non sono disponibili dati o un numero positivo di byte letti altrimenti. Il ServerSocket.il metodo di scrittura non si blocca se c’è spazio libero nel buffer di output del socket.

public class NioNonBlockingEchoServer { public static void main(String args) throws IOException {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);
serverSocketChannel.bind(new InetSocketAddress(7000)); while (active) {
SocketChannel socketChannel = serverSocketChannel.accept(); // non-blocking
if (socketChannel != null) {
socketChannel.configureBlocking(false); ByteBuffer buffer = ByteBuffer.allocate(1024);
while (true) {
buffer.clear();
int read = socketChannel.read(buffer); // non-blocking
if (read < 0) {
break;
} buffer.flip();
socketChannel.write(buffer); // can be non-blocking
} socketChannel.close();
}
} serverSocketChannel.close();
}
}

Multiplexing NIO echo server

Nell’esempio seguente, il modello di I/O multiplexing viene implementato in un’API Java NIO di echo server.

Durante l’inizializzazione, più oggetti ServerSocketChannel, configurati in modalità non bloccante, vengono registrati sullo stesso oggetto Selettore con SelectionKey.Argomento OP_ACCEPT per specificare che un evento di accettazione di connessione interessante.

Nel ciclo principale, il Selettore.selezionare metodo blocca fino a quando si verifica almeno uno degli eventi registrati. Quindi il Selettore.Il metodo selectedKeys restituisce un set di oggetti SelectionKey per i quali si sono verificati eventi. Iterando attraverso gli oggetti SelectionKey, è possibile determinare quale evento I/O (connect, accept, read, write) è avvenuto e quali oggetti socket (ServerSocketChannel, SocketChannel) sono stati associati a quell’evento.

L’indicazione di un tasto di selezione che un canale è pronto per alcune operazioni è un suggerimento, non una garanzia.

public class NioMultiplexingEchoServer { public static void main(String args) throws IOException {
final int ports = 8;
ServerSocketChannel serverSocketChannels = new ServerSocketChannel; Selector selector = Selector.open(); for (int p = 0; p < ports; p++) {
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannels = serverSocketChannel;
serverSocketChannel.configureBlocking(false);
serverSocketChannel.bind(new InetSocketAddress("localhost", 7000 + p)); serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
} while (active) {
selector.select(); // blocking Iterator<SelectionKey> keysIterator = selector.selectedKeys().iterator();
while (keysIterator.hasNext()) {
SelectionKey key = keysIterator.next(); if (key.isAcceptable()) {
accept(selector, key);
}
if (key.isReadable()) {
keysIterator.remove();
read(selector, key);
}
if (key.isWritable()) {
keysIterator.remove();
write(key);
}
}
} for (ServerSocketChannel serverSocketChannel : serverSocketChannels) {
serverSocketChannel.close();
}
}
}

Quando un oggetto SelectionKey indica che si è verificato un evento di accettazione della connessione, viene creato ServerSocketChannel.accetta chiamata (che può essere un non-blocking) per accettare la connessione. Successivamente, un nuovo oggetto SocketChannel viene configurato in modalità non bloccante e viene registrato sullo stesso oggetto Selettore con SelectionKey.Argomento OP_READ per specificare che ora un evento di lettura interessante.

private static void accept(Selector selector, SelectionKey key) throws IOException {
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = serverSocketChannel.accept(); // can be non-blocking
if (socketChannel != null) {
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_READ);
}
}

Quando un oggetto SelectionKey indica che si è verificato un evento di lettura, viene creato SocketChannel.read call (che può essere un non-blocking) per leggere i dati dall’oggetto SocketChannel in un nuovo oggetto ByteByffer. Successivamente, l’oggetto SocketChannel viene registrato sullo stesso oggetto Selettore con SelectionKey.Argomento OP_WRITE per specificare che ora un evento di scrittura interessante. Inoltre, questo oggetto ByteBuffer viene utilizzato durante la registrazione come allegato.

private static void read(Selector selector, SelectionKey key) throws IOException {
SocketChannel socketChannel = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(1024);
socketChannel.read(buffer); // can be non-blocking buffer.flip();
socketChannel.register(selector, SelectionKey.OP_WRITE, buffer);
}

Quando un oggetto SelectionKeys indica che si è verificato un evento di scrittura, viene creato SocketChannel.write call (che può essere un non-blocking) per scrivere dati nell’oggetto SocketChannel dall’oggetto ByteByffer, estratto da SelectionKey.metodo di fissaggio. Dopo di ciò, il socketcanale.cloase chiamata chiude la connessione.

private static void write(SelectionKey key) throws IOException {
SocketChannel socketChannel = (SocketChannel) key.channel(); ByteBuffer buffer = (ByteBuffer) key.attachment(); socketChannel.write(buffer); // can be non-blocking
socketChannel.close();
}

Dopo ogni lettura o scrittura l’oggetto SelectionKey viene rimosso dal set degli oggetti SelectionKey per impedirne il riutilizzo. Ma l’oggetto SelectionKey per l’accettazione della connessione non viene rimosso per avere la possibilità di eseguire la successiva operazione simile.

Asynchronous NIO2 echo server

Nell’esempio seguente, il modello di I/O asincrono viene implementato in un server echo con API Java NIO2. Le classi AsynchronousServerSocketChannel, AsynchronousSocketChannel qui vengono utilizzate con il meccanismo dei gestori di completamento.

Il canale asynchronousserversocket.il metodo accept avvia un’operazione di accettazione della connessione asincrona.

public class Nio2CompletionHandlerEchoServer { public static void main(String args) throws IOException {
AsynchronousServerSocketChannel serverSocketChannel = AsynchronousServerSocketChannel.open();
serverSocketChannel.bind(new InetSocketAddress(7000)); AcceptCompletionHandler acceptCompletionHandler = new AcceptCompletionHandler(serverSocketChannel);
serverSocketChannel.accept(null, acceptCompletionHandler); System.in.read();
}
}

Quando viene accettata una connessione (o l’operazione non riesce), viene chiamata la classe AcceptCompletionHandler, che da AsynchronousSocketChannel.leggi (destinazione ByteBuffer, Un allegato, CompletionHandler < Integer,? il metodo super A > handler) avvia un’operazione di lettura asincrona dall’oggetto AsynchronousSocketChannel a un nuovo oggetto ByteBuffer.

class AcceptCompletionHandler implements CompletionHandler<AsynchronousSocketChannel, Void> { private final AsynchronousServerSocketChannel serverSocketChannel; AcceptCompletionHandler(AsynchronousServerSocketChannel serverSocketChannel) {
this.serverSocketChannel = serverSocketChannel;
} @Override
public void completed(AsynchronousSocketChannel socketChannel, Void attachment) {
serverSocketChannel.accept(null, this); // non-blocking ByteBuffer buffer = ByteBuffer.allocate(1024);
ReadCompletionHandler readCompletionHandler = new ReadCompletionHandler(socketChannel, buffer);
socketChannel.read(buffer, null, readCompletionHandler); // non-blocking
} @Override
public void failed(Throwable t, Void attachment) {
// exception handling
}
}

Quando l’operazione di lettura viene completata (o non riesce), viene chiamata la classe ReadCompletionHandler, che da AsynchronousSocketChannel.scrivi (fonte ByteBuffer, Un allegato, CompletionHandler < Integer,? il metodo super A > handler) avvia un’operazione di scrittura asincrona sull’oggetto AsynchronousSocketChannel dall’oggetto ByteBuffer.

class ReadCompletionHandler implements CompletionHandler<Integer, Void> { private final AsynchronousSocketChannel socketChannel;
private final ByteBuffer buffer; ReadCompletionHandler(AsynchronousSocketChannel socketChannel, ByteBuffer buffer) {
this.socketChannel = socketChannel;
this.buffer = buffer;
} @Override
public void completed(Integer bytesRead, Void attachment) {
WriteCompletionHandler writeCompletionHandler = new WriteCompletionHandler(socketChannel);
buffer.flip();
socketChannel.write(buffer, null, writeCompletionHandler); // non-blocking
} @Override
public void failed(Throwable t, Void attachment) {
// exception handling
}
}

Quando l’operazione di scrittura viene completata (o non riesce), viene chiamata la classe WriteCompletionHandler, che da AsynchronousSocketChannel.il metodo close chiude la connessione.

class WriteCompletionHandler implements CompletionHandler<Integer, Void> { private final AsynchronousSocketChannel socketChannel; WriteCompletionHandler(AsynchronousSocketChannel socketChannel) {
this.socketChannel = socketChannel;
} @Override
public void completed(Integer bytesWritten, Void attachment) {
try {
socketChannel.close();
} catch (IOException e) {
// exception handling
}
} @Override
public void failed(Throwable t, Void attachment) {
// exception handling
}
}

In questo esempio, le operazioni di I/O asincrone vengono eseguite senza allegati, poiché tutti gli oggetti necessari (AsynchronousSocketChannel, ByteBuffer) vengono passati come argomenti del costruttore per i gestori di completamento appropriati.

Conclusione

La scelta del modello I/O per la comunicazione dei socket dipende dai parametri del traffico. Se le richieste di I/O sono lunghe e poco frequenti, l’I / O asincrono è generalmente una buona scelta. Tuttavia, se le richieste di I/O sono brevi e veloci, il sovraccarico delle chiamate al kernel di elaborazione può rendere l’I / O sincrono molto migliore.

Nonostante che Java fornisce un modo standard di eseguire socket I/O nei diversi sistemi operativi, le prestazioni effettive possono variare in modo significativo a seconda della loro implementazione. È possibile iniziare a studiare queste differenze con il noto articolo di Dan Kegel The C10K problem.

Esempi di codice completi sono disponibili nel repository GitHub.

Lascia un commento

Il tuo indirizzo email non sarà pubblicato.