Java Socket i tcpNoDelay

Autor
Damian
Terlecki
17 minut
Java

Standardowy interfejs socketów w Javie oferuje podstawowe API do obsługi aplikacji bazujących na komunikacji TCP/UDP. Wśród dostępnych opcji konfiguracyjnych socketu nie znajdziemy niskopoziomowych flag specyficznych dla poszczególnych systemów operacyjnych (np. IP_DONTFRAG/IP_MTU_DISCOVER). Niemniej jednak nowe flagi (np. TCP_QUICKACK – Java 10) dodawane są do JDK i dają nam możliwość konfiguracji na poziomie interfejsu NetworkChannel, z której to klasy możemy wyłuskać sam socket.

Z punktu widzenia standardowych opcji, ciekawą flagą jest TCP_NODELAY. Flaga ta odpowiada za możliwość wyłączenie algorytmu Nagle'a. W uproszczeniu algorytm ten buforuje dane do momentu nadejścia potwierdzenia doręczenia (ACK) poprzedniego pakietu danych bądź osiągnięcia limitu danych w buforze (MSS/MTU).

Wireshark – analiza pakietów TCP

TCP_NODELAY

Aby przetestować zachowanie systemu przy użyciu flagi TCP_NODELAY przyda nam się prosty serwer oraz klient obsługujący komunikację TCP. Serwer w tym przypadku posłuży jedynie do akceptacji połączenia i odczytania przesłąnych danych. Dobrym pomysłem będzie postawienie go gdzieś w internecie, bądź w oddzielnej sieci, bez korzystania z interfejsu pętli zwrotnej. Loopback jest bowiem interfejsem emulowanym i może nie oddawać w pełni warunków produkcyjnych (chyba że komunikację planujemy oprzeć na tym samym hoście).

public class Server {
    public static void main(String[] args) throws IOException {
        String envPort = System.getenv("PORT");
        int port = Integer.parseInt(envPort == null ? "14321" : envPort);
        ServerSocket serverSocket = new ServerSocket(port);
        System.out.printf("Starting socket server on port %d%n", port);
        while (true) {
            Socket socket = serverSocket.accept();
            InputStream inputStream = socket.getInputStream();
            int read = inputStream.read();
            while (read >= 0) {
                System.out.print(read);
                read = inputStream.read();
            }
            System.out.println();
        }
    }
}

Sama komunikacja będzie ciekawa głównie z poziomu klienta. Zakładając wyłączenie buforowanie, możemy spodziewać się, że wysyłane pakiety będą w granicach bufora zdefiniowanego po stronie aplikacji, a nie rozmiaru MSS. A więc do przetestowania na standardowym interfejsie internetowym o MTU 1500, spróbujemy wysyłać pakiety o wielkości 1200 bajtów.

public class Client {
    private static final String HOST = "54.156.x.x";
    private static final int PORT = 1432;

    private static final byte[] PAYLOAD = new byte[1200];
    private static final int CONSECUTIVE_CONNECTIONS = 1000;
    private static final int CONSECUTIVE_REQUESTS_PER_CONNECTION = 10;
    private static final int FORCED_DELAY_MS = 15;

    public static void main(String[] args) throws IOException, InterruptedException {
        for (int j = 0; j < CONSECUTIVE_CONNECTIONS; j++) {
            System.out.printf("Starting iteration %d/%d%n", j, CONSECUTIVE_CONNECTIONS);
            Socket socket = new Socket(HOST, PORT);
            socket.setTcpNoDelay(true);
            OutputStream outputStream = socket.getOutputStream();
            for (int i = 0; i < CONSECUTIVE_REQUESTS_PER_CONNECTION; i++) {
                outputStream.write(PAYLOAD);
                Thread.sleep(FORCED_DELAY_MS);
            }
            socket.close();
        }
    }
}

Oprócz ustawienia flagi TCP_NODELAY, pomiędzy każdym wysłanym pakietem dodamy (bądź nie) pewne niewielkie opóźnienie na poziomie milisekund. Komunikacja TCP odbywa się strumieniowo, nie ma tu właściwego polecenia opróżniającego bufor, a właściwą obsługą procesu zajmuje się system operacyjny.

Wyniki

Jak się okazuje to, czy pakiet zostanie wysłany w momencie zapisu do strumienia socketa, zależy w dużej mierze od systemu. To w gestii zaimplementowanych algorytmów leży rezultat naszego testu:

SystemtcpNoDelayWymuszone opóźnienieLiczba pakietów wysłanych bez buforowania
Windows 10.0.19042 false 0 ms 33.33%
Windows 10.0.19042 true 0 ms 100%
Linux 5.8.0-55-generic false 0 ms 22.22%
Linux 5.8.0-55-generic false 15 ms 22.22%
Linux 5.8.0-55-generic true 0 ms 33.33%
Linux 5.8.0-55-generic true 5 ms 98%
Linux 5.8.0-55-generic true 10 ms 99.7%
Linux 5.8.0-55-generic true 15 ms 99.9%

Windows 10 zachowuje się w tym przypadku tak jak można by się tego spodziewać po opisie flagi TCP_NODELAY. W standardowej konfiguracji pierwszy (zgodnie z algorytmem) i ostatni (przy zakmięciu połączenia) pakiety są wysyłane natychmiastowo. Przy wyłączeniu algorytmu Nagle'a wszystkie pakiety wysyłane są z maksymalnym rozmiarem 1200 bajtów (nie licząc rozmiaru nagłówków).

W przypadku Linuksa sprawa jest nieco bardziej skomplikowana. Potrzebne jest wymuszenie opóźnienia pomiędzy kolejnymi pakietami, aby system mimo wyłączonego algorytmu nie zbuforował nam danych wyjściowych. Jest to wynikiem kilku składowych. Wpływ na to ma między innymi:

  • Algorytm unikania przeciążania (ang. congestation), który powoduje buforowanie w wyniku dużej ilości niepotwierdzonych jeszcze pakietów, szczególnie w początkowej fazie połączenia;
  • Standardowo włączona flaga net.ipv4.tcp_autocorking=1 pomijająca wysyłanie pakietu w określonych przypadkach;
  • Inne algorytmy, np. TSQ minimalizujący agregację danych.

Podsumowując, flaga TCP_NODELAY możliwa do ustawienia na sockecie w Javie nie jest jedynie wskazówką dla systemu (jak można by się spodziewać po jej podobnych), a faktycznie działa. Mimo obecności na większości współczesnych systemów, rezultaty jej wykorzystania mogą nieco różnić się w zależności od systemu operacyjnego. Przed skorzystaniem warto więc przeanalizować, czy pasuje ona do naszego rozwiązania i środowiska uruchomieniowego.