Java Sockets E / A: blockierend, nicht blockierend und asynchron

Foto von Evi Radauscher auf Unsplash

Bei der Beschreibung von E / A werden die Begriffe nicht blockierend und asynchron häufig synonym verwendet, aber es gibt einen signifikanten Unterschied zwischen ihnen. In diesem Artikel werden die theoretischen und praktischen Unterschiede zwischen nicht blockierenden und asynchronen Sockets-E / A-Operationen in Java beschrieben.

Sockets sind Endpunkte für die bidirektionale Kommunikation über TCP- und UDP-Protokolle. Java Sockets APIs sind Adapter für die entsprechende Funktionalität der Betriebssysteme. Unterstützt die Kommunikation in POSIX-kompatiblen Betriebssystemen (Unix, Linux, Mac OS X, BSD, Solaris, AIX, etc.) wird von Berkeley Sockets durchgeführt. Die Socket-Kommunikation in Windows wird von Winsock ausgeführt, das ebenfalls auf Berkeley-Sockets mit zusätzlichen Funktionen basiert, um dem Windows-Programmiermodell zu entsprechen.

Die POSIX-Definitionen

In diesem Artikel werden vereinfachte Definitionen aus der POSIX-Spezifikation verwendet.

Blockierter Thread – Ein Thread, der auf eine Bedingung wartet, bevor die Ausführung fortgesetzt werden kann.

Blocking – eine Eigenschaft eines Sockets, die dazu führt, dass Aufrufe auf die Ausführung der angeforderten Aktion warten, bevor sie zurückgegeben werden.

Nicht blockierend – Eine Eigenschaft eines Sockets, die bewirkt, dass Aufrufe ohne Verzögerung zurückgegeben werden, wenn festgestellt wird, dass die angeforderte Aktion nicht ohne eine unbekannte Verzögerung abgeschlossen werden kann.

Synchroner E / A—Vorgang – Ein E / A-Vorgang, der bewirkt, dass der anfordernde Thread blockiert wird, bis dieser E / A-Vorgang abgeschlossen ist.

Asynchrone E / A—Operation – eine E / A-Operation, die selbst nicht dazu führt, dass der anfordernde Thread blockiert wird; Dies impliziert, dass der Thread und die E / A-Operation möglicherweise gleichzeitig ausgeführt werden.

Gemäß der POSIX-Spezifikation ist der Unterschied zwischen den Begriffen nicht blockierend und asynchron offensichtlich:

  • Nicht blockierend – eine Eigenschaft eines Sockets, die bewirkt, dass Aufrufe ohne Verzögerung zurückgegeben werden
  • asynchrone E / A — eine Eigenschaft für eine E / A-Operation (Lesen oder Schreiben), die gleichzeitig mit dem anfordernden Thread ausgeführt wird

E / A-Modelle

Die folgenden E / A-Modelle sind für POSIX-kompatible Betriebssysteme am häufigsten:

  • Blockierendes E / A-Modell
  • nicht blockierendes E / A-Modell
  • E / A-Multiplexmodell
  • signalgesteuertes E / A-Modell
  • asynchrones E / A-Modell

Blockierendes E / A-Modell

Im blockierenden E / A-Modell führt die Anwendung einen blockierenden Systemaufruf durch, bis Daten im Kernel empfangen und aus dem Kernelbereich in den Benutzerbereich kopiert werden.

 blockierendes E / A-Modell

blockierendes E / A-Modell

Pros:

  • Das einfachste zu implementierende E/ A-Modell

Nachteile:

  • Die Anwendung ist blockiert

Nicht blockierendes E / A-Modell

Im nicht blockierenden E / A-Modell führt die Anwendung einen Systemaufruf durch, der sofort eine von zwei Antworten zurückgibt:

  • Wenn der E / A-Vorgang sofort abgeschlossen werden kann, werden die Daten zurückgegeben
  • Wenn der E / A-Vorgang nicht sofort abgeschlossen werden kann, wird ein Fehlercode zurückgegeben, der angibt, dass der E / A-Vorgang blockiert wird oder das Gerät vorübergehend nicht verfügbar ist

Um den E / A-Vorgang abzuschließen, sollte die Anwendung bis zum Abschluss warten (wiederholte Systemaufrufe ausführen).

 nicht blockierendes E / A-Modell

nicht blockierendes E / A-Modell

Pros:

  • Die Anwendung wird nicht blockiert

Nachteile:

  • Die Anwendung sollte beschäftigt sein-warten Sie bis zur Fertigstellung, das würde viele Benutzer-Kernel-Kontextwechsel verursachen
  • Dieses Modell kann eine E / A-Latenz einführen, da es eine Lücke zwischen der Datenverfügbarkeit im Kernel und dem Datenlesen durch die Anwendung geben kann

E / A-Multiplexmodell

Im E / A-Multiplexmodell (auch als Nicht-Kernel bezeichnet)

 E/A-Multiplexmodell

E/A-Multiplexmodell

Pros:

  • Es ist möglich, E / A-Vorgänge für mehrere Deskriptoren in einem Thread auszuführen

Nachteile:

  • Die Anwendung ist beim Select-Systemaufruf weiterhin blockiert
  • Nicht alle Betriebssysteme unterstützen dieses Modell effizient

Signalgesteuertes E / A-Modell

Im signalgesteuerten E / A-Modell führt die Anwendung einen nicht blockierenden Aufruf durch und registriert einen Signalhandler. Wenn ein Deskriptor für eine E / A-Operation bereit ist, wird ein Signal für die Anwendung generiert. Dann kopiert der Signalhandler die Daten aus dem Kernelbereich in den Benutzerbereich.

 signalgesteuertes E/A-Modell

signalgesteuertes E/A-Modell

Pros:

  • Die Anwendung ist nicht blockiert
  • Signale können eine gute Leistung liefern

Nachteile:

  • Nicht alle Betriebssysteme unterstützen Signale

Asynchrones E / A-Modell

Im asynchronen E / A-Modell (auch als überlappendes E / A-Modell bezeichnet) führt die Anwendung den nicht blockierenden Aufruf durch und startet eine Hintergrundoperation im Kernel. Wenn der Vorgang abgeschlossen ist (Daten werden im Kernel empfangen und aus dem Kernelbereich in den Benutzerbereich kopiert), wird ein Completion-Rückruf generiert, um den E / A-Vorgang abzuschließen.

Ein Unterschied zwischen dem asynchronen E / A-Modell und dem signalgesteuerten E / A-Modell besteht darin, dass bei signalgesteuerter E / A der Kernel der Anwendung mitteilt, wann eine E / A-Operation initiiert werden kann, bei asynchroner E / A-Modell teilt der Kernel der Anwendung jedoch mit, wann eine E / A-Operation abgeschlossen ist.

 asynchrones E / A-Modell

asynchrones E / A-Modell

Pros:

  • Die Anwendung wird nicht blockiert
  • Dieses Modell bietet die beste Leistung

Nachteile:

  • Das komplizierteste zu implementierende E / A-Modell
  • Nicht alle Betriebssysteme unterstützen dieses Modell effizient

Java-E / A-APIs

Die Java-E / A-API basiert auf Streams (InputStream, OutputStream), die einen blockierenden, einseitigen Datenfluss darstellen.

Java NIO API

Die Java NIO API basiert auf den Klassen Channel, Buffer und Selector, die Adapter für Low-Level-E / A-Vorgänge von Betriebssystemen sind.

Die Channel-Klasse stellt eine Verbindung zu einer Entität (Hardwaregerät, Datei, Socket, Softwarekomponente usw.) dar, die E / A-Vorgänge (Lesen oder Schreiben) ausführen kann.

Im Vergleich zu unidirektionalen Streams sind Kanäle bidirektional.

Die Pufferklasse ist ein Datencontainer fester Größe mit zusätzlichen Methoden zum Lesen und Schreiben von Daten. Alle Kanaldaten werden über den Puffer verarbeitet, aber niemals direkt: Alle Daten, die an einen Kanal gesendet werden, werden in einen Puffer geschrieben, alle Daten, die von einem Kanal empfangen werden, werden in einen Puffer gelesen.

Im Vergleich zu Streams, die byteorientiert sind, sind Kanäle blockorientiert. Byteorientierte E / A ist einfacher, kann aber für einige E / A-Entitäten ziemlich langsam sein. Blockorientierte E / A kann viel schneller sein, ist aber komplizierter.

Die Selector-Klasse ermöglicht das Abonnieren von Ereignissen aus vielen registrierten SelectableChannel-Objekten in einem einzigen Aufruf. Wenn Ereignisse eintreffen, sendet ein Selektorobjekt sie an die entsprechenden Ereignishandler.

Java NIO2 API

Java NIO2 API basiert auf asynchronen Kanälen (AsynchronousServerSocketChannel, AsynchronousSocketChannel usw.), die asynchrone E / A-Vorgänge unterstützen (Verbinden, Lesen oder Schreiben, Fehlerbehandlung).

Die asynchronen Kanäle bieten zwei Mechanismen zur Steuerung asynchroner E/A-Vorgänge. Der erste Mechanismus besteht darin, ein Java zurückzugeben.util.gleichzeitig.Future-Objekt, das eine ausstehende Operation modelliert und verwendet werden kann, um den Status abzufragen und das Ergebnis zu erhalten. Der zweite Mechanismus besteht darin, ein Java an die Operation zu übergeben.nio.TV.completionHandler-Objekt, das Handlermethoden definiert, die ausgeführt werden, nachdem der Vorgang abgeschlossen oder fehlgeschlagen ist. Die bereitgestellten APIs für beide Mechanismen sind gleichwertig.

Asynchrone Kanäle bieten eine Standardmethode zur plattformunabhängigen Ausführung asynchroner Vorgänge. Die Menge, in der Java Sockets API die nativen asynchronen Funktionen eines Betriebssystems nutzen kann, hängt jedoch von der Unterstützung für diese Plattform ab.

Socket Echo Server

Die meisten der oben genannten E / A-Modelle sind hier in Echo-Servern und -Clients mit Java-Sockets-APIs implementiert. Die Echo-Server und -Clients arbeiten nach folgendem Algorithmus:

  1. Ein Server hört auf einen Socket an einem registrierten TCP-Port 7000
  2. Ein Client verbindet sich von einem Socket an einem dynamischen TCP-Port mit dem Server-Socket
  3. Der Client liest eine Eingabezeichenfolge von der Konsole und sendet die Bytes von seinem Socket an den Server-Socket
  4. Der Server empfängt die Bytes von seinem Socket und sendet sie an den Client-Socket zurück
  5. der Client empfängt die Bytes von seinem Socket und schreibt den Echo-String auf die Konsole
  6. Wenn der Client die gleiche Anzahl von Bytes empfängt, die er gesendet hat, trennt er die Verbindung vom Server
  7. Wenn der Server eine spezielle Zeichenfolge empfängt, hört er auf zu hören

Die Konvertierung zwischen Zeichenfolgen und Bytes erfolgt hier explizit in UTF-8-Codierung.

Weiter sind nur vereinfachte Codes für Echo-Server vorgesehen. Der Link zu den vollständigen Codes für Echo-Server und -Clients ist in der Schlussfolgerung enthalten.

Blocking IO echo server

Im folgenden Beispiel ist das blocking I/O-Modell in einem Echo Server mit Java IO API implementiert.

Der ServerSocket.accept-Methode blockiert, bis eine Verbindung akzeptiert wird. Der InputStream.lesen Sie Methodenblöcke, bis Eingabedaten verfügbar sind oder ein Client getrennt wird. Der OutputStream.schreiben Sie Methodenblöcke, bis alle Ausgabedaten geschrieben sind.

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();
}
}

Blocking NIO echo server

Im folgenden Beispiel wird das Blocking I/O-Modell in einem Echo Server mit Java NIO API implementiert.

Die Objekte ServerSocketChannel und SocketChannel sind standardmäßig im Blockierungsmodus konfiguriert. Der ServerSocketChannel.die accept-Methode blockiert und gibt ein SocketChannel-Objekt zurück, wenn eine Verbindung akzeptiert wird. Der ServerSocket.lesen Sie Methodenblöcke, bis Eingabedaten verfügbar sind oder ein Client getrennt wird. Der ServerSocket.schreiben Sie Methodenblöcke, bis alle Ausgabedaten geschrieben sind.

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();
}
}

Nicht blockierender NIO-Echoserver

Im folgenden Beispiel ist das nicht blockierende E/ A-Modell in einem Echoserver mit Java NIO API implementiert.

Die Objekte ServerSocketChannel und SocketChannel werden explizit im nicht blockierenden Modus konfiguriert. Der ServerSocketChannel.die accept-Methode blockiert nicht und gibt null zurück, wenn noch keine Verbindung akzeptiert wird, oder andernfalls ein SocketChannel-Objekt. Der ServerSocket.read blockiert nicht und gibt 0 zurück, wenn keine Daten verfügbar sind, oder andernfalls eine positive Anzahl von gelesenen Bytes. Der ServerSocket.die Schreibmethode blockiert nicht, wenn im Ausgabepuffer des Sockets freier Speicherplatz vorhanden ist.

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

Im folgenden Beispiel ist das Multiplexing-E/A-Modell in einer Echo Server Java NIO API implementiert.

Während der Initialisierung werden mehrere ServerSocketChannel-Objekte, die im nicht blockierenden Modus konfiguriert sind, auf demselben Selector-Objekt mit dem SelectionKey registriert.OP_ACCEPT Argument, um anzugeben, dass ein Ereignis der Verbindungsannahme interessant ist.

In der Hauptschleife der Selektor.select method blockiert, bis mindestens eines der registrierten Ereignisse eintritt. Dann der Selektor.selectedKeys Methode gibt einen Satz der SelectionKey Objekte, für die Ereignisse aufgetreten sind. Durch Durchlaufen der SelectionKey-Objekte kann ermittelt werden, welches E / A-Ereignis (Verbinden, Akzeptieren, Lesen, Schreiben) aufgetreten ist und welche Sockets-Objekte (ServerSocketChannel, SocketChannel) diesem Ereignis zugeordnet wurden.

Die Anzeige einer Auswahltaste, dass ein Kanal für eine bestimmte Operation bereit ist, ist ein Hinweis, keine Garantie.

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();
}
}
}

Wenn ein SelectionKey-Objekt angibt, dass ein Verbindungsannahmeereignis aufgetreten ist, wird der ServerSocketChannel erstellt.anruf annehmen (der nicht blockierend sein kann), um die Verbindung anzunehmen. Danach wird ein neues SocketChannel-Objekt im nicht blockierenden Modus konfiguriert und mit dem SelectionKey auf demselben Selector-Objekt registriert.OP_READ Argument, um anzugeben, dass jetzt ein Ereignis des Lesens interessant ist.

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);
}
}

Wenn ein SelectionKey-Objekt anzeigt, dass ein Leseereignis stattgefunden hat, wird es zum SocketChannel .read-Aufruf (der nicht blockierend sein kann) zum Lesen von Daten aus dem SocketChannel-Objekt in ein neues ByteByffer-Objekt. Danach wird das SocketChannel-Objekt mit dem SelectionKey auf demselben Selector-Objekt registriert.OP_WRITE Argument, um anzugeben, dass jetzt ein Ereignis von write interessant ist. Zusätzlich wird dieses ByteBuffer-Objekt bei der Registrierung als Anhang verwendet.

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);
}

Wenn ein SelectionKeys-Objekt anzeigt, dass ein Schreibereignis stattgefunden hat, wird es zum SocketChannel .write-Aufruf (der nicht blockierend sein kann), um Daten aus dem ByteByffer-Objekt, das aus dem SelectionKey extrahiert wurde, in das SocketChannel-Objekt zu schreiben.befestigung methode. Danach wird der SocketChannel.cloase call schließt die Verbindung.

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();
}

Nach jedem Lesen oder Schreiben wird das SelectionKey-Objekt aus der Menge der SelectionKey-Objekte entfernt, um seine Wiederverwendung zu verhindern. Das SelectionKey-Objekt für die Verbindungsannahme wird jedoch nicht entfernt, um die nächste ähnliche Operation ausführen zu können.

Asynchroner NIO2-Echoserver

Im folgenden Beispiel ist das asynchrone E/ A-Modell in einem Echoserver mit Java NIO2 API implementiert. Die AsynchronousServerSocketChannel- und AsynchronousSocketChannel-Klassen werden hier mit dem Completion-Handler-Mechanismus verwendet.

Der AsynchronousServerSocketChannel.accept-Methode initiiert einen asynchronen Verbindungsannahmevorgang.

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();
}
}

Wenn eine Verbindung akzeptiert wird (oder der Vorgang fehlschlägt), wird die AcceptCompletionHandler-Klasse aufgerufen, die vom AsynchronousSocketChannel .read(ByteBuffer), Ein Anhang, completionHandler<Integer,? super A> Handler) -Methode initiiert einen asynchronen Lesevorgang vom AsynchronousSocketChannel-Objekt zu einem neuen ByteBuffer-Objekt.

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
}
}

Wenn der Lesevorgang abgeschlossen ist (oder fehlschlägt), wird die ReadCompletionHandler-Klasse aufgerufen, die vom AsynchronousSocketChannel .schreiben(ByteBuffer Quelle, Ein Anhang, completionHandler<Integer,? super A> Handler) Methode initiiert einen asynchronen Schreibvorgang für das AsynchronousSocketChannel-Objekt aus dem ByteBuffer-Objekt.

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
}
}

Wenn der Schreibvorgang abgeschlossen ist (oder fehlschlägt), wird die WriteCompletionHandler-Klasse aufgerufen, die vom AsynchronousSocketChannel .close-Methode schließt die Verbindung.

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 diesem Beispiel werden asynchrone E / A-Vorgänge ohne Anhang ausgeführt, da alle erforderlichen Objekte (AsynchronousSocketChannel, ByteBuffer) als Konstruktorargumente für die entsprechenden Vervollständigungshandler übergeben werden.

Fazit

Die Wahl des E / A-Modells für die Socket-Kommunikation hängt von den Parametern des Datenverkehrs ab. Wenn E / A-Anforderungen lang und selten sind, ist asynchrone E / A im Allgemeinen eine gute Wahl. Wenn E / A-Anforderungen jedoch kurz und schnell sind, kann der Overhead der Verarbeitung von Kernelaufrufen die synchrone E / A erheblich verbessern.

Obwohl Java eine Standardmethode für die Ausführung von Sockets-E / A in den verschiedenen Betriebssystemen bietet, kann die tatsächliche Leistung je nach Implementierung erheblich variieren. Es ist möglich, diese Unterschiede mit Dan Kegels bekanntem Artikel The C10K problem zu untersuchen.

Vollständige Codebeispiele sind im GitHub-Repository verfügbar.

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.