Vorlesung 2
Netzwerkprogrammierung über Sockets Teil 1

Netzwerkprogrammierung über Sockets ermöglicht die Kommunikation zwischen verschiedenen Computern über ein Netzwerk. Sockets sind die grundlegenden Bausteine für die Netzwerkkommunikation. Sie bieten eine Möglichkeit, Daten zwischen Anwendungen zu senden und zu empfangen, die auf verschiedenen Rechnern laufen.

Arten von Sockets

Steam Sockets (TCP)

  • Bieten eine zuverlässige, verbindungsorientierte Kommunikation.
  • Daten werden in der richtigen Reihenfolge und ohne Duplikate übertragen.
  • Verwendung: Web-Server, E-Mail, Dateiübertragungen.

Datagram Sockets (UDP)

  • Bieten eine verbindungslose Kommunikation.
  • Datenpakete werden ohne Garantie der Zustellung oder Reihenfolge gesendet.
  • Verwendung: Videostreaming, VoIP, Online-Gaming.

Java unterstützt auch Kommunikation über UDP Sockets durch die Klassen DatagramPacket und DatagramSocket.

Grundlagen der Socket-Programmierung

Erstellen eines Sockets

import java.net.*;

// Erstellen eines TCP/IP Sockets
Socket tcpSocket = new Socket("localhost", 8080);

// Erstellen eines UDP Sockets
DatagramSocket udpSocket = new DatagramSocket();

Binden eines Sockets an eine Adresse und Port

// Für ServerSocket in TCP
ServerSocket serverSocket = new ServerSocket(8080);

Hören auf Verbindungen (nur für TCP)

// Server wartet auf Verbindungen
Socket clientSocket = serverSocket.accept();

Akzeptieren einer Verbindung (nur für TCP)

// Verbindung wird akzeptiert
Socket clientSocket = serverSocket.accept();

Senden und Empfangen von Daten

// TCP
BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);

String inputLine;
while ((inputLine = in.readLine()) != null) {
    out.println("Daten vom Server: " + inputLine);
}

// UDP
byte[] sendData = new byte[1024];
byte[] receiveData = new byte[1024];

DatagramPacket sendPacket = new DatagramPacket(sendData, sendData.length, InetAddress.getByName("localhost"), 8080);
udpSocket.send(sendPacket);

DatagramPacket receivePacket = new DatagramPacket(receiveData, receiveData.length);
udpSocket.receive(receivePacket);

Schließen des Sockets

tcpSocket.close();
udpSocket.close();

Beispiel: Einfache Client-Server-Kommunikation

Server (TCP)

import java.net.*;
import java.io.*;

public class TCPServer {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8080);
        System.out.println("Warten auf eine Verbindung...");
        Socket clientSocket = serverSocket.accept();
        System.out.println("Verbunden mit " + clientSocket.getInetAddress());

        PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true);
        BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));

        String inputLine;
        while ((inputLine = in.readLine()) != null) {
            System.out.println("Empfangen: " + inputLine);
            out.println("Hallo vom Server");
        }

        in.close();
        out.close();
        clientSocket.close();
        serverSocket.close();
    }
}

Client (TCP)

import java.net.*;
import java.io.*;

public class TCPClient {
    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("localhost", 8080);
        PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
        BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()));

        out.println("Hallo Server");
        String response = in.readLine();
        System.out.println("Empfangen: " + response);

        in.close();
        out.close();
        socket.close();
    }
}

Anwendungsfälle

  • Web-Server und Web-Clients: Web-Browser verwenden TCP-Sockets, um mit Web-Servern zu kommunizieren.
  • Echtzeitkommunikation: Anwendungen wie VoIP oder Online-Spiele nutzen UDP-Sockets für schnelle und effiziente Kommunikation.
  • Dateiübertragungen: Protokolle wie FTP verwenden Sockets, um Dateien zwischen Server und Client zu übertragen.

Sockets bieten eine leistungsstarke und flexible Möglichkeit, Anwendungen über Netzwerke hinweg zu verbinden, und sind ein grundlegendes Werkzeug in der Netzwerkprogrammierung.

Theorie zu Sockets und Ports

Was ist ein Port?

Ein Port ist ein numerischer Bezeichner, der spezifische Prozesse oder Netzwerkdienste auf einem Host in einem Computernetzwerk identifiziert. Jeder Port ist mit einer IP-Adresse und dem Protokoll (TCP oder UDP) verbunden, um eine eindeutige Verbindung zu ermöglichen.

  • Portnummern: Sie reichen von 0 bis 65535, wobei Ports 0-1023 als &quotwell-known ports&quot bekannt sind und für häufig verwendete Protokolle reserviert sind (z.B. HTTP auf Port 80, HTTPS auf Port 443).
  • Registrierte Ports: Diese reichen von 1024 bis 49151 und werden für weniger gängige Anwendungen verwendet.
  • Dynamische und private Ports: Diese reichen von 49152 bis 65535 und werden oft für temporäre Verbindungen oder private Anwendungen verwendet.

Ports sind notwendig, um verschiedene Netzwerkdienste auf einem einzelnen Host zu unterscheiden. Ein Computer kann mehrere Dienste gleichzeitig anbieten, wie z.B. einen Webserver, einen E-Mail-Server und einen FTP-Server. Jeder dieser Dienste lauscht auf einem anderen Port.

Was ist ein Socket?

Ein Socket ist eine Kombination aus einer IP-Adresse und einer Portnummer und dient als Endpunkt für die Kommunikation. Sockets ermöglichen es, dass Daten zwischen zwei Endpunkten in einem Netzwerk gesendet und empfangen werden können.

  • Socket-Adresse: Eine Kombination aus einer IP-Adresse und einer Portnummer (z.B. 192.168.1.1:8080).
  • Verbindungsorientierte Sockets: Diese verwenden das TCP-Protokoll, das eine zuverlässige Datenübertragung gewährleistet. TCP-Sockets garantieren die Zustellung von Daten in der richtigen Reihenfolge und ohne Verlust.
  • Verbindungslos Sockets: Diese verwenden das UDP-Protokoll, das schnelle und effiziente Datenübertragungen ermöglicht, jedoch ohne Garantie der Zustellung. UDP-Sockets sind nützlich für Anwendungen, bei denen Geschwindigkeit wichtiger ist als Zuverlässigkeit, wie z.B. beim Videostreaming oder Online-Gaming.

Sockets sind ein grundlegendes Konzept in der Netzwerkprogrammierung, da sie es Anwendungen ermöglichen, miteinander zu kommunizieren. Ein typisches Beispiel ist ein Webbrowser, der eine Verbindung zu einem Webserver herstellt. Der Browser verwendet einen Socket, um eine Anfrage an den Server zu senden, und der Server verwendet einen Socket, um die Antwort zurück an den Browser zu senden.

Wichtige Konzepte in der Socket-Programmierung

  • Binden: Der Vorgang, bei dem ein Socket an eine spezifische IP-Adresse und Portnummer gebunden wird, sodass er auf eingehende Verbindungen oder Datenpakete lauschen kann.
  • Hören (Listen): Der Vorgang, bei dem ein Server-Socket auf Verbindungsanforderungen von Clients wartet.
  • Akzeptieren: Der Vorgang, bei dem ein Server-Socket eine eingehende Verbindungsanforderung eines Client-Sockets akzeptiert und eine neue Socket-Verbindung erstellt.
  • Verbinden: Der Vorgang, bei dem ein Client-Socket eine Verbindung zu einem Server-Socket herstellt.
  • Senden und Empfangen: Die grundlegenden Operationen, die es Sockets ermöglichen, Daten zwischen Client und Server zu übertragen.
  • Schließen: Der Vorgang, bei dem eine Socket-Verbindung beendet wird.

Das Verständnis von Ports und Sockets ist entscheidend für die Entwicklung von Netzwerkapplikationen, da sie die Mechanismen bereitstellen, durch die Computer im Netzwerk miteinander kommunizieren können. In der Praxis werden Sockets häufig in vielen Anwendungen verwendet, von einfachen Chat-Programmen bis hin zu komplexen verteilten Systemen.

Theorie zur Programmierung eines einfachen Chat-Clients in Java

Einführung

Ein Chat-Client in Java nutzt Sockets zur Kommunikation mit einem Server. Der Server fungiert als Vermittler und leitet Nachrichten zwischen verschiedenen Clients weiter. Die Kommunikation erfolgt über das TCP-Protokoll, da es zuverlässige, verbindungsorientierte Datenübertragungen ermöglicht.

Grundlagen

Sockets:

Ein Socket ist eine Kombination aus einer IP-Adresse und einer Portnummer. Es dient als Endpunkt für die Netzwerkkommunikation. Es gibt zwei Hauptarten von Sockets:

  • TCP Sockets: Für verbindungsorientierte, zuverlässige Kommunikation.
  • UDP Sockets: Für verbindungslose, weniger zuverlässige Kommunikation.

Ports:

Ports sind numerische Bezeichner, die spezifische Netzwerkdienste auf einem Host identifizieren. Jeder Dienst (z.B. HTTP, FTP) hat einen Standardport (z.B. HTTP nutzt Port 80).

Komponenten eines Chat-Clients

  • Client-Socket: Wird verwendet, um eine Verbindung zu einem Server-Socket herzustellen. In Java wird ein `Socket` Objekt verwendet.
  • Server-Socket: Lauscht auf einem bestimmten Port auf Verbindungsanfragen von Clients. In Java wird ein `ServerSocket` Objekt verwendet.
  • Input/Output Streams: Zum Senden und Empfangen von Nachrichten über die Socket-Verbindung. `InputStream` und `OutputStream` Klassen in Java ermöglichen den Datentransfer.

Schritt-für-Schritt Anleitung

Server-Programm:

import java.io.*;
import java.net.*;
import java.util.*;

public class ChatServer {
    private static Set<Socket> clientSockets = new HashSet<>();

    public static void main(String[] args) {
        try (ServerSocket serverSocket = new ServerSocket(12345)) {
            System.out.println("Server is listening on port 12345");

            while (true) {
                Socket socket = serverSocket.accept();
                clientSockets.add(socket);
                new ClientHandler(socket).start();
            }
        } catch (IOException ex) {
            System.out.println("Server exception: " + ex.getMessage());
            ex.printStackTrace();
        }
    }

    private static class ClientHandler extends Thread {
        private Socket socket;

        public ClientHandler(Socket socket) {
            this.socket = socket;
        }

        public void run() {
            try (InputStream input = socket.getInputStream();
                 BufferedReader reader = new BufferedReader(new InputStreamReader(input))) {

                String message;
                while ((message = reader.readLine()) != null) {
                    System.out.println("Received: " + message);
                    broadcast(message, socket);
                }
            } catch (IOException ex) {
                System.out.println("Server exception: " + ex.getMessage());
                ex.printStackTrace();
            } finally {
                try {
                    socket.close();
                    clientSockets.remove(socket);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }

        private void broadcast(String message, Socket senderSocket) {
            for (Socket clientSocket : clientSockets) {
                if (clientSocket != senderSocket) {
                    try {
                        OutputStream output = clientSocket.getOutputStream();
                        PrintWriter writer = new PrintWriter(output, true);
                        writer.println(message);
                    } catch (IOException ex) {
                        System.out.println("Error sending message: " + ex.getMessage());
                        ex.printStackTrace();
                    }
                }
            }
        }
    }
}

Client-Programm:

import java.io.*;
import java.net.*;

public class ChatClient {
    public static void main(String[] args) {
        try (Socket socket = new Socket("localhost", 12345)) {
            new ReadThread(socket).start();
            new WriteThread(socket).start();
        } catch (IOException ex) {
            System.out.println("Client exception: " + ex.getMessage());
            ex.printStackTrace();
        }
    }

    private static class ReadThread extends Thread {
        private BufferedReader reader;

        public ReadThread(Socket socket) {
            try {
                InputStream input = socket.getInputStream();
                reader = new BufferedReader(new InputStreamReader(input));
            } catch (IOException ex) {
                System.out.println("Error getting input stream: " + ex.getMessage());
                ex.printStackTrace();
            }
        }

        public void run() {
            try {
                String message;
                while ((message = reader.readLine()) != null) {
                    System.out.println(message);
                }
            } catch (IOException ex) {
                System.out.println("Error reading from server: " + ex.getMessage());
                ex.printStackTrace();
            }
        }
    }

    private static class WriteThread extends Thread {
        private PrintWriter writer;
        private BufferedReader consoleReader;

        public WriteThread(Socket socket) {
            try {
                OutputStream output = socket.getOutputStream();
                writer = new PrintWriter(output, true);
                consoleReader = new BufferedReader(new InputStreamReader(System.in));
            } catch (IOException ex) {
                System.out.println("Error getting output stream: " + ex.getMessage());
                ex.printStackTrace();
            }
        }

        public void run() {
            try {
                String message;
                while ((message = consoleReader.readLine()) != null) {
                    writer.println(message);
                }
            } catch (IOException ex) {
                System.out.println("Error writing to server: " + ex.getMessage());
                ex.printStackTrace();
            }
        }
    }
}

Zusammenfassung

Die Programmierung eines einfachen Chat-Clients in Java verdeutlicht, wie Sockets zur Netzwerkkommunikation genutzt werden können. Ein Server-Socket lauscht auf Verbindungen und verwaltet mehrere Client-Verbindungen mittels Threads. Ein Client-Socket stellt die Verbindung zum Server her und ermöglicht das Senden und Empfangen von Nachrichten. Diese Grundlagen sind essenziell für die Entwicklung komplexerer Netzwerkapplikationen.

Threads in Java

Einführung

Ein Thread ist der kleinste Ausführungseinheit in einem Programm. In Java kann ein Programm mehrere Threads gleichzeitig ausführen, wodurch parallele und nebenläufige Programmierung ermöglicht wird. Dies ist besonders nützlich für Aufgaben wie die gleichzeitige Bearbeitung mehrerer Netzwerkverbindungen oder die Verbesserung der Anwendungsleistung durch parallele Verarbeitung.

Grundlagen

Erstellen von Threads:

In Java können Threads auf zwei Arten erstellt werden:

  • Durch Implementieren des Runnable Interface und Übergeben einer Instanz an einen Thread.
  • Durch Erben von der Thread Klasse und Überschreiben der run() Methode.

Zustände eines Threads:

Ein Thread kann verschiedene Zustände haben:

  • New: Der Thread wurde erstellt, aber noch nicht gestartet.
  • Runnable: Der Thread ist bereit zur Ausführung und wartet auf CPU-Zeit.
  • Blocked: Der Thread wartet darauf, eine Ressource freizugeben.
  • Waiting: Der Thread wartet auf die Benachrichtigung eines anderen Threads.
  • Timed Waiting: Der Thread wartet für eine bestimmte Zeitspanne.
  • Terminated: Der Thread hat seine Ausführung beendet.

Beispiel: Implementierung von Threads

Implementierung des Runnable Interface:

public class MyRunnable implements Runnable {
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName() + " - " + i);
        }
    }

    public static void main(String[] args) {
        Thread thread1 = new Thread(new MyRunnable());
        thread1.start();

        Thread thread2 = new Thread(new MyRunnable());
        thread2.start();
    }
}

Erben von der Thread Klasse:

public class MyThread extends Thread {
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName() + " - " + i);
        }
    }

    public static void main(String[] args) {
        MyThread thread1 = new MyThread();
        thread1.start();

        MyThread thread2 = new MyThread();
        thread2.start();
    }
}

Synchronisation von Threads

Bei der Arbeit mit mehreren Threads können Synchronisationsprobleme auftreten, wenn mehrere Threads gleichzeitig auf dieselbe Ressource zugreifen. In Java wird dies durch die Verwendung des synchronized Schlüsselworts gelöst.

public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        };

        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Final count: " + counter.getCount());
    }
}

Fazit

Threads in Java ermöglichen parallele und nebenläufige Programmierung, was die Leistung und Effizienz von Anwendungen verbessern kann. Die Implementierung von Threads kann entweder durch das Implementieren des Runnable Interface oder durch das Erben von der Thread Klasse erfolgen. Synchronisation ist wichtig, um Datenkonsistenz bei gleichzeitigen Zugriffen zu gewährleisten.

Das Philosophenproblem

Einführung

Das Philosophenproblem ist ein klassisches Synchronisationsproblem in der Informatik, das ursprünglich von Edsger Dijkstra formuliert wurde. Es beschreibt eine Situation, in der fünf Philosophen abwechselnd denken und essen. Sie sitzen um einen runden Tisch, und zwischen jedem Paar von Philosophen befindet sich eine Gabel. Ein Philosoph muss beide Gabeln nehmen, um zu essen. Das Problem stellt sicher, dass keine zwei Philosophen gleichzeitig dieselbe Gabel benutzen können, um Deadlocks und Ressourcenverklemmung zu vermeiden.

Das Szenario

Es gibt fünf Philosophen, die ihr Leben damit verbringen, abwechselnd zu denken und zu essen. Jeder Philosoph benötigt zwei Gabeln, um zu essen. Zwischen jedem Paar von Philosophen liegt eine Gabel. Daher müssen sich die Philosophen die Gabeln teilen.

Probleme und Lösungen

Deadlock

Ein Deadlock tritt auf, wenn jeder Philosoph eine Gabel aufhebt und auf die zweite Gabel wartet. Da jede Gabel bereits von einem anderen Philosophen gehalten wird, warten alle Philosophen unendlich lange.

Lösungen

Es gibt verschiedene Ansätze zur Lösung des Philosophenproblems:

  • Asymmetrische Lösung: Ein Philosoph nimmt zuerst die linke, dann die rechte Gabel, während der nächste Philosoph zuerst die rechte, dann die linke Gabel nimmt. Dies verhindert Deadlocks.
  • Arbitration: Ein externer Schiedsrichter gibt die Erlaubnis zum Aufheben der Gabeln. Nur ein Philosoph kann die Gabeln gleichzeitig aufnehmen.
  • Benutzung von Ressourcenhierarchien: Jeder Philosoph nimmt zuerst die Gabel mit der niedrigeren Nummer und dann die Gabel mit der höheren Nummer auf.

Beispielimplementierung in Java

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class PhilosopherProblem {
    private static class Philosopher extends Thread {
        private final Lock leftFork;
        private final Lock rightFork;

        public Philosopher(Lock leftFork, Lock rightFork) {
            this.leftFork = leftFork;
            this.rightFork = rightFork;
        }

        private void doAction(String action) throws InterruptedException {
            System.out.println(Thread.currentThread().getName() + " " + action);
            Thread.sleep(((int) (Math.random() * 100)));
        }

        public void run() {
            try {
                while (true) {
                    // thinking
                    doAction("Thinking");
                    leftFork.lock();
                    try {
                        doAction("Picked up left fork");
                        rightFork.lock();
                        try {
                            // eating
                            doAction("Picked up right fork - eating"); 
                            doAction("Put down right fork");
                        } finally {
                            rightFork.unlock();
                        }
                        doAction("Put down left fork");
                    } finally {
                        leftFork.unlock();
                    }
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
        }
    }

    public static void main(String[] args) {
        Philosopher[] philosophers = new Philosopher[5];
        Lock[] forks = new ReentrantLock[philosophers.length];

        for (int i = 0; i < forks.length; i++) {
            forks[i] = new ReentrantLock();
        }

        for (int i = 0; i < philosophers.length; i++) {
            Lock leftFork = forks[i];
            Lock rightFork = forks[(i + 1) % forks.length];

            // The last philosopher picks up the right fork first
            if (i == philosophers.length - 1) {
                philosophers[i] = new Philosopher(rightFork, leftFork);
            } else {
                philosophers[i] = new Philosopher(leftFork, rightFork);
            }

            philosophers[i].start();
        }
    }
}

Fazit

Das Philosophenproblem veranschaulicht die Herausforderungen der Synchronisation und der Vermeidung von Deadlocks in parallelen Systemen. Es zeigt, wie wichtig es ist, geeignete Synchronisationsmechanismen und Strategien zu entwickeln, um Ressourcenverklemmung und -verhungern zu verhindern.