Zum Inhalt
wannacode

Hotelbuchungen

Kurz gesagt: Ein Projekt für Hotelbuchungen: Ein Server und viele Clients buchen, prüfen und stornieren gleichzeitig Zimmer, und wenn zwei im selben Moment dasselbe Zimmer wollen, bekommt es nur einer.

Problem

Ein Zimmer, zwei Bestätigungen

Ein kleines Hotel hat vier Zimmer. Buchungen kommen von überall: von der Website, von der Rezeption, von Reisebüros. An einem Freitagabend buchen zwei Agenturen in derselben Sekunde das letzte Doppelzimmer. Beide bekommen eine Bestätigung. Am Samstag stehen zwei Familien vor dem Zimmer.

Wir bauen ein System, in dem der Server die Zimmer und die Buchungen verwaltet und die Clients Anfragen über das Netz schicken. Ein Client kann:

  • die freien Zimmer sehen;
  • ein Zimmer prüfen;
  • ein Zimmer auf seinen Namen buchen;
  • seine eigene Buchung stornieren.

Und wenn zwei Clients gleichzeitig dasselbe Zimmer buchen, hat nur einer Erfolg. Das beweisen wir mit einem Programm, das bei jedem Lauf dieselbe Ausgabe liefert.

Wir bauen von unten nach oben: das Modell ohne Netz, die Regeln und die Fehler, das Protokoll, der Server und der Client, die gleichzeitigen Anfragen und am Ende alles in einem Programm.

Genauer, für Neugierige
Das Modell ist in allen vier Sprachen des Kurses gleich. In Python schicken die Clients wie in Java Textzeilen über einen Socket, und jeder Client hat seinen eigenen Thread. Ein Lock (eine Sperre) schützt die Zimmer: with self._lock.

Die Idee

Das Zimmer

Ein Zimmer hat eine Nummer, einen Typ, einen Preis pro Nacht und einen Zustand: frei oder belegt.

class Room {
private final int number;
private final String type;
private final int price;
private boolean free = true;
public Room(int number, String type, int price) {
this.number = number;
this.type = type;
this.price = price;
}
public int getNumber() {
return number;
}
public String getType() {
return type;
}
public int getPrice() {
return price;
}
public boolean isFree() {
return free;
}
public void occupy() {
free = false;
}
public void release() {
free = true;
}
@Override
public String toString() {
return number + ", " + type + ", " + price + " € pro Nacht, " + (free ? "frei" : "belegt");
}
}
  • Nummer, Typ und Preis ändern sich nicht. Nur der Zustand ändert sich, und zwar nur über zwei Methoden mit klaren Namen: occupy und release.
  • Das Zimmer weiß nicht, wer darin wohnt. Das Zimmer geht an jeden Client, der fragt, und der Name des Gastes soll das Hotel nicht verlassen.
Genauer, für Neugierige
Die Felder beginnen mit _: Das heißt „von außen nicht anfassen“. Python verbietet es nicht, aber so ist die Konvention. Nummer, Typ und Preis werden über @property ohne Klammern gelesen (room.number), und eine Änderung von außen (room.number = 5) bricht mit AttributeError ab, weil die Property keinen Setter hat. Der Typ des Zimmers heißt kind, weil type in Python eine eingebaute Funktion ist. Für mehr als zwei Zustände nimmt man eine Aufzählung (Enum): class RoomState(Enum) aus dem Modul enum.

Sieh es dir an

Das Hotel und die Buchung

Das Hotel hält die Zimmer in einer Liste und die Gäste in einem Dictionary „Zimmernummer → Name“:
private final List<Room> rooms = new ArrayList<>();
private final Map<Integer, String> guests = new HashMap<>();
public void add(Room room) {
rooms.add(room);
}
public List<Room> freeRooms() {
return rooms.stream().filter(Room::isFree).toList();
}
public Room check(int number) {
return find(number);
}

_find ist privat (mit _, per Konvention): Sie sucht das Zimmer nach Nummer oder wirft eine Exception. So kann niemand von außen ein Zimmer nehmen und es an den Regeln vorbei belegen:

private Room find(int number) {
for (Room room : rooms) {
if (room.getNumber() == number) {
return room;
}
}
throw new RoomNotFoundException("Es gibt kein Zimmer " + number + ".");
}

Die Buchung prüft den Namen, findet das Zimmer und prüft, ob es frei ist. Erst dann belegt sie es:

public void reserve(int number, String guest) {
if (guest.isBlank()) {
throw new HotelException("Der Name des Gastes ist Pflicht.");
}
Room room = find(number);
if (!room.isFree()) {
throw new RoomTakenException("Zimmer " + number + " ist schon belegt.");
}
room.occupy();
guests.put(number, guest);
}
Hotel hotel = new Hotel();
hotel.add(new Room(101, "Einzelzimmer", 60));
hotel.add(new Room(102, "Doppelzimmer", 85));
hotel.add(new Room(201, "Doppelzimmer", 95));
hotel.add(new Room(202, "Suite", 140));
for (Room room : hotel.freeRooms()) {
System.out.println(room);
}
hotel.reserve(102, "Anna Petrova");
System.out.println("Nach der Buchung: " + hotel.check(102));
System.out.println("Freie Zimmer: " + hotel.freeRooms().size());
Ausgabe
101, Einzelzimmer, 60 € pro Nacht, frei
102, Doppelzimmer, 85 € pro Nacht, frei
201, Doppelzimmer, 95 € pro Nacht, frei
202, Suite, 140 € pro Nacht, frei
Nach der Buchung: 102, Doppelzimmer, 85 € pro Nacht, belegt
Freie Zimmer: 3

Die Idee

Stornieren und die Regeln

Nicht jede Anfrage lässt sich ausführen: Das Zimmer ist belegt, es gibt kein Zimmer 305, ein Gast storniert die Buchung eines anderen. Das sind Absagen nach den Regeln, deshalb sind es eigene Exceptions mit einer gemeinsamen Basisklasse:

class HotelException extends RuntimeException {
public HotelException(String message) {
super(message);
}
}
class RoomNotFoundException extends HotelException {
public RoomNotFoundException(String message) {
super(message);
}
}
class RoomTakenException extends HotelException {
public RoomTakenException(String message) {
super(message);
}
}

Das Stornieren prüft, dass das Zimmer belegt ist und dass derselbe Gast es storniert:

public void cancel(int number, String guest) {
Room room = find(number);
if (room.isFree()) {
throw new HotelException("Zimmer " + number + " ist nicht gebucht.");
}
if (!guests.get(number).equals(guest)) {
throw new HotelException("Zimmer " + number + " ist von einem anderen Gast gebucht.");
}
room.release();
guests.remove(number);
}

Die Projektaufgabe verlangt, das System in verschiedenen Szenarien zu prüfen: buchen, prüfen und stornieren, sowohl wenn alles gültig ist als auch wenn nicht. Jedes Szenario ist eine Zeile in der Ausgabe:

Hotel hotel = new Hotel();
hotel.add(new Room(101, "Einzelzimmer", 60));
hotel.add(new Room(102, "Doppelzimmer", 85));
hotel.add(new Room(201, "Doppelzimmer", 95));
hotel.add(new Room(202, "Suite", 140));
hotel.reserve(102, "Anna Petrova");
System.out.println("1. Freies Zimmer: " + hotel.check(102));
try {
hotel.reserve(102, "Boris Iliev");
} catch (RoomTakenException e) {
System.out.println("2. Belegtes Zimmer: " + e.getMessage());
}
try {
hotel.cancel(102, "Boris Iliev");
} catch (HotelException e) {
System.out.println("3. Fremde Buchung: " + e.getMessage());
}
hotel.cancel(102, "Anna Petrova");
System.out.println("4. Stornierung: " + hotel.check(102));
try {
hotel.cancel(102, "Anna Petrova");
} catch (HotelException e) {
System.out.println("5. Zweite Stornierung: " + e.getMessage());
}
try {
hotel.reserve(305, "Anna Petrova");
} catch (RoomNotFoundException e) {
System.out.println("6. Kein solches Zimmer: " + e.getMessage());
}
try {
hotel.reserve(201, " ");
} catch (HotelException e) {
System.out.println("7. Ohne Namen: " + e.getMessage());
}
Ausgabe
1. Freies Zimmer: 102, Doppelzimmer, 85 € pro Nacht, belegt
2. Belegtes Zimmer: Zimmer 102 ist schon belegt.
3. Fremde Buchung: Zimmer 102 ist von einem anderen Gast gebucht.
4. Stornierung: 102, Doppelzimmer, 85 € pro Nacht, frei
5. Zweite Stornierung: Zimmer 102 ist nicht gebucht.
6. Kein solches Zimmer: Es gibt kein Zimmer 305.
7. Ohne Namen: Der Name des Gastes ist Pflicht.

Die konkrete Exception sagt, was das Problem ist. HotelError fängt alle Absagen auf einmal: Der Server wird nur sie fangen und in eine Antwort für den Client verwandeln.

Genauer, für Neugierige
  • Checked Exceptions gibt es nicht: Keine Methode erklärt, was sie wirft. Deshalb fängt die Rezeption sie an einer Stelle.
  • Szenarien wie diese sind natürliche Tests. Mit unittest wird jedes zu einer Methode eines TestCase, und with self.assertRaises(RoomTakenError): um hotel.reserve(102, "Boris Iliev") prüft die Exception.

Probier es aus

Wer ist in 201

Was gibt das Programm aus?

hotel = Hotel()
hotel.add(Room(101, "Einzelzimmer", 60))
hotel.add(Room(102, "Doppelzimmer", 85))
hotel.add(Room(201, "Doppelzimmer", 95))
hotel.add(Room(202, "Suite", 140))
hotel.reserve(101, "Anna Petrova")
hotel.reserve(201, "Boris Iliev")
hotel.cancel(101, "Anna Petrova")
try:
hotel.reserve(201, "Vera Koleva")
print("Vera ist in 201")
except RoomTakenError:
print("Absage für Vera")
print(len(hotel.free_rooms()))
Antwort

Die Idee

Das Protokoll: eine Zeile „BEFEHL:Text“

Jede Nachricht ist eine Zeile BEFEHL:Text, und der Server antwortet mit einer Zeile:

Der Client schickt Der Server antwortet
FREI OK: und die freien Zimmer, getrennt durch ;
PRÜFEN:102 OK:102, Doppelzimmer, 85 € pro Nacht, belegt
BUCHEN:102;Anna Petrova OK:Zimmer 102 gehört dir, Anna Petrova. oder FEHLER: und der Grund
STORNIEREN:102;Anna Petrova OK:Die Buchung für Zimmer 102 ist storniert. oder FEHLER: und der Grund
ENDE TSCHÜSS, und der Server schließt die Verbindung

Die Wörter des Protokolls sind Konstanten an einer Stelle (in Python ist eine Konstante eine Konvention: Großbuchstaben, und niemand ändert sie):

class Protocol:
FREE = "FREI"
CHECK = "PRÜFEN"
RESERVE = "BUCHEN"
CANCEL = "STORNIEREN"
EXIT = "ENDE"
OK = "OK"
ERROR = "FEHLER"
BYE = "TSCHÜSS"

Die Rezeption übersetzt eine Zeile des Protokolls in einen Aufruf des Hotels und zurück:

class Reception:
def __init__(self, hotel):
self._hotel = hotel
...
def handle(self, line):
command, _, text = line.partition(":")
try:
match command:
case Protocol.FREE:
return f"{Protocol.OK}:{self._join(self._hotel.free_rooms())}"
case Protocol.CHECK:
return f"{Protocol.OK}:{self._hotel.check(self._number(text))}"
case Protocol.RESERVE:
return self._reserve(text)
case Protocol.CANCEL:
return self._cancel(text)
case _:
raise HotelError(f"Unbekannter Befehl {command}.")
except HotelError as e:
return f"{Protocol.ERROR}:{e}"
def _reserve(self, text):
number_text, guest = self._number_and_guest(text)
number = self._number(number_text)
self._hotel.reserve(number, guest)
return f"{Protocol.OK}:Zimmer {number} gehört dir, {guest}."
def _cancel(self, text):
number_text, guest = self._number_and_guest(text)
number = self._number(number_text)
self._hotel.cancel(number, guest)
return f"{Protocol.OK}:Die Buchung für Zimmer {number} ist storniert."
@staticmethod
def _number_and_guest(text):
parts = text.split(";", 1)
if len(parts) != 2:
raise HotelError("Schreib eine Nummer und einen Namen, zum Beispiel „102;Anna Petrova“.")
return parts
@staticmethod
def _number(text):
try:
return int(text)
except ValueError:
raise HotelError("Die Zimmernummer ist eine ganze Zahl.")
@staticmethod
def _join(rooms):
if not rooms:
return "Es gibt keine freien Zimmer."
return "; ".join(str(room) for room in rooms)
line.partition(":") teilt die Zeile in drei Teile: den Text bis zum ersten :, das : selbst und den Rest. Ohne : ist der Befehl die ganze Zeile und der Text leer. match vergleicht den Befehl mit jeder Konstante, und case _ fängt alles andere.

Die Rezeption merkt sich nichts über den Client: Jede Zeile trägt alles, was nötig ist, sogar den Namen des Gastes. Deshalb reicht eine Rezeption für alle Clients. Das lässt sich ohne Netz prüfen:

hotel = Hotel()
hotel.add(Room(101, "Einzelzimmer", 60))
hotel.add(Room(102, "Doppelzimmer", 85))
hotel.add(Room(201, "Doppelzimmer", 95))
hotel.add(Room(202, "Suite", 140))
reception = Reception(hotel)
lines = [
"BUCHEN:102;Anna Petrova",
"PRÜFEN:102",
"BUCHEN:102;Boris Iliev",
"STORNIEREN:102",
"PRÜFEN:einhundertzwei",
"ZEIGEN",
]
for line in lines:
print(f"> {line}")
print(reception.handle(line))
Ausgabe
> BUCHEN:102;Anna Petrova
OK:Zimmer 102 gehört dir, Anna Petrova.
> PRÜFEN:102
OK:102, Doppelzimmer, 85 € pro Nacht, belegt
> BUCHEN:102;Boris Iliev
FEHLER:Zimmer 102 ist schon belegt.
> STORNIEREN:102
FEHLER:Schreib eine Nummer und einen Namen, zum Beispiel „102;Anna Petrova“.
> PRÜFEN:einhundertzwei
FEHLER:Die Zimmernummer ist eine ganze Zahl.
> ZEIGEN
FEHLER:Unbekannter Befehl ZEIGEN.
Genauer, für Neugierige
  • Die Befehle könnten eine Aufzählung (Enum) sein: class Command(StrEnum) aus dem Modul enum. Dann sind sie ein eigener Typ mit einer geschlossenen Liste von Werten. Im Kurs sind es Konstanten, weil wir Enums nicht behandelt haben.
  • Das Protokoll ist zustandslos: Jede Zeile steht für sich. Das macht es leicht zu prüfen und leicht, mehr Clients zu bedienen. Müsste der Client zuerst sagen „ich bin Anna“, bräuchte jeder Client seine eigene Rezeption, wie die Sitzung im Autohaus.

Sieh es dir an

Der Server

Jeder verbundene Client bekommt seinen eigenen ClientHandler, und die Rezeption ist gemeinsam:

class ClientHandler:
def __init__(self, connection, reception):
self._connection = connection
self._reception = reception
def run(self):
try:
with (
self._connection,
self._connection.makefile("r", encoding="utf-8") as reader,
self._connection.makefile("w", encoding="utf-8") as writer,
):
for line in reader:
line = line.rstrip("\n")
if line == Protocol.EXIT:
break
print(self._reception.handle(line), file=writer, flush=True)
print(Protocol.BYE, file=writer, flush=True)
except OSError as e:
print(f"Die Verbindung zum Client ist abgebrochen: {e}")

makefile macht aus dem Socket eine Datei: Die Zeilen werden mit for gelesen und mit print(..., file=writer) geschrieben. flush=True schickt die Zeile sofort und nicht erst, wenn der Puffer voll ist. with (...) schließt am Ende die Verbindung und beide Dateien, egal wie die Methode verlassen wurde.

HotelServer nimmt die Clients in einem eigenen Thread an und gibt jeden an einen Pool von 10 Threads weiter (ThreadPoolExecutor):

class HotelServer:
def __init__(self, port, hotel):
self._server_socket = socket.create_server(("localhost", port))
self._pool = ThreadPoolExecutor(max_workers=10)
self._reception = Reception(hotel)
def start(self):
acceptor = threading.Thread(target=self._accept_all, daemon=True)
acceptor.start()
def _accept_all(self):
while True:
try:
client, _ = self._server_socket.accept()
except OSError:
return
self._pool.submit(ClientHandler(client, self._reception).run)
def stop(self):
self._server_socket.close()
self._pool.shutdown()
  • Zehn Clients werden gleichzeitig bedient. Der elfte wird angenommen und wartet in der Warteschlange des Pools, bis jemand geht.
  • Alle zehn Threads rufen dasselbe Hotel auf. Deshalb muss das Hotel seine Zimmer schützen. Wie, gleich.
  • Der Thread, der die Clients annimmt, ist ein daemon: Er hält das Programm nicht am Leben, wenn alles andere fertig ist.

Sieh es dir an

Der Client

Der Client schickt eine Zeile und liest die Antwort. In Java ist send zwei Methoden, mit Text und ohne. In Python ist es eine, und text=None heißt, dass der Text weggelassen werden kann. close() sagt ENDE und schließt beide Dateien und die Verbindung: Der Socket schließt sich wirklich erst, wenn auch die Dateien aus makefile geschlossen sind.
class HotelClient:
def __init__(self, host, port):
self._connection = socket.create_connection((host, port))
self._in = self._connection.makefile("r", encoding="utf-8")
self._out = self._connection.makefile("w", encoding="utf-8")
def send(self, command, text=None):
line = command if text is None else f"{command}:{text}"
print(line, file=self._out, flush=True)
return self._in.readline().rstrip("\n")
def close(self):
self.send(Protocol.EXIT)
self._in.close()
self._out.close()
self._connection.close()

Server und Client in einem Programm:

hotel = Hotel()
hotel.add(Room(101, "Einzelzimmer", 60))
hotel.add(Room(102, "Doppelzimmer", 85))
hotel.add(Room(201, "Doppelzimmer", 95))
hotel.add(Room(202, "Suite", 140))
server = HotelServer(5341, hotel)
server.start()
client = HotelClient("localhost", 5341)
print(client.send(Protocol.RESERVE, "102;Anna Petrova"))
print(client.send(Protocol.CHECK, "102"))
print(client.send(Protocol.FREE))
print(client.send(Protocol.CANCEL, "102;Anna Petrova"))
print(client.send(Protocol.CHECK, "305"))
client.close()
server.stop()
Ausgabe
OK:Zimmer 102 gehört dir, Anna Petrova.
OK:102, Doppelzimmer, 85 € pro Nacht, belegt
OK:101, Einzelzimmer, 60 € pro Nacht, frei; 201, Doppelzimmer, 95 € pro Nacht, frei; 202, Suite, 140 € pro Nacht, frei
OK:Die Buchung für Zimmer 102 ist storniert.
FEHLER:Es gibt kein Zimmer 305.

Die Idee

Zwei für ein Zimmer

Eine Buchung sind zwei Schritte: prüfen, dass das Zimmer frei ist, dann es belegen. Laufen zwei Anfragen gleichzeitig, kann Folgendes passieren:
Agentur A Agentur B
1 prüft: 102 ist frei
2 prüft: 102 ist frei
3 belegt 102 für ihren Gast
4 belegt 102 für ihren Gast und trägt ihn ein

Beide bekommen eine Bestätigung. In keiner einzelnen Codezeile steckt ein Fehler. Der Fehler ist, dass sich die Schritte der beiden Anfragen verschränken.

Die Lösung: Ins Hotel kommt immer nur eine Anfrage auf einmal. Dann ist „prüfen und belegen“ unteilbar (atomar): Niemand kann zwischen die Prüfung und das Belegen kommen. In Python macht das ein Lock im Hotel, threading.RLock(), mit with self._lock: um den Rumpf jeder öffentlichen Methode:
def __init__(self):
self._rooms = []
self._guests = {}
self._lock = threading.RLock()
def reserve(self, number, guest):
with self._lock:
if not guest.strip():
raise HotelError("Der Name des Gastes ist Pflicht.")
room = self._find(number)
if not room.is_free:
raise RoomTakenError(f"Zimmer {number} ist schon belegt.")
room.occupy()
self._guests[number] = guest

def reserve(self, number, guest):
with self._lock:
if not guest.strip():
raise HotelError("Der Name des Gastes ist Pflicht.")
room = self._find(number)
if not room.is_free:
raise RoomTakenError(f"Zimmer {number} ist schon belegt.")
room.occupy()
self._guests[number] = guest
Schritt für Schritt, in Worten
  1. Zimmer 102 ist frei. Anna Petrova und Boris Iliev buchen es im selben Moment.
  2. Zuerst ohne with self._lock. Das ist eine mögliche Reihenfolge: Annas Anfrage prüft und sieht „frei“.
  3. Boris’ Anfrage prüft, bevor Anna das Zimmer belegt hat: Auch sie sieht „frei“.
  4. Anna belegt das Zimmer und trägt ihren Namen ein.
  5. Boris belegt das Zimmer auch und trägt seinen Namen ein. Beide haben eine Bestätigung für dasselbe Zimmer.
  6. Von vorn, mit with self._lock: Nur ein Thread auf einmal kommt ins Hotel.
  7. Sagen wir, Anna ist zuerst dran: Sie nimmt den Lock. Prüfen und Belegen sind ein Schritt, und niemand kommt dazwischen.
  8. Boris kommt beim Hotel an und wartet, bis der Lock frei ist.
  9. Boris hat den Lock. Jetzt sieht die Prüfung „belegt“.
  10. RoomTakenError. Die Rezeption schickt ihn als Fehlerzeile zurück, und Boris kann nach einem anderen Zimmer fragen.
  11. Das Programm aus der Lektion zählt die Erfolgreichen: 1 pro Zimmer. Wer zuerst dran war, ist bei jedem Lauf anders.

Der Beweis: Zwei Clients buchen 102 gleichzeitig, dann buchen zwanzig Agenturen 201 gleichzeitig. Die Antworten werden gesammelt, und das Programm gibt aus, wie viele Erfolg hatten:

def reserve(port, room, guest):
try:
client = HotelClient("localhost", port)
reply = client.send(Protocol.RESERVE, f"{room};{guest}")
client.close()
return reply
except OSError as e:
return f"{Protocol.ERROR}:{e}"
def race(port, room, guests):
with ThreadPoolExecutor(max_workers=len(guests)) as clients:
replies = list(clients.map(lambda guest: reserve(port, room, guest), guests))
reserved = 0
for reply in replies:
if reply.startswith(Protocol.OK):
reserved += 1
return reserved
hotel = Hotel()
hotel.add(Room(101, "Einzelzimmer", 60))
hotel.add(Room(102, "Doppelzimmer", 85))
hotel.add(Room(201, "Doppelzimmer", 95))
hotel.add(Room(202, "Suite", 140))
server = HotelServer(5342, hotel)
server.start()
two = ["Anna Petrova", "Boris Iliev"]
print(f"Zwei für Zimmer 102, erfolgreich: {race(5342, 102, two)}")
agencies = [f"Agentur {i + 1}" for i in range(20)]
print(f"Zwanzig für Zimmer 201, erfolgreich: {race(5342, 201, agencies)}")
desk = HotelClient("localhost", 5342)
print(desk.send(Protocol.FREE))
desk.close()
server.stop()
Ausgabe
Zwei für Zimmer 102, erfolgreich: 1
Zwanzig für Zimmer 201, erfolgreich: 1
OK:101, Einzelzimmer, 60 € pro Nacht, frei; 202, Suite, 140 € pro Nacht, frei
Der Pool hat so viele Threads, wie es Gäste gibt, deshalb starten alle Anfragen gleichzeitig. map wartet auf die Antworten und gibt sie in der Reihenfolge der Gäste zurück, und race zählt die Erfolgreichen.

Wer das Zimmer bekommen hat, ist bei jedem Lauf anders, deshalb gibt das Programm es nicht aus. Wie viele Erfolg hatten, ist immer gleich: einer pro Zimmer.

Genauer, für Neugierige
  • Ohne das Sperren würde das Programm trotzdem fast immer 1 sagen: Das Fenster zwischen Prüfen und Belegen ist klein. Aber „fast immer“ heißt, dass einmal in Tausenden Buchungen zwei Gäste vor dem Zimmer stehen. So einen Fehler findest du nicht durch Ausführen, sondern durch Nachdenken: Welche Daten sind gemeinsam, und wer ändert sie gleichzeitig. Das GIL von Python schützt „prüfen und belegen“ nicht: Der Thread kann zwischen zwei Zeilen gewechselt werden. Steht zwischen Prüfen und Belegen etwas Langsames, zum Beispiel eine Datenbankabfrage, kommt der andere Thread fast sicher dazwischen.
  • self._lock sperrt das ganze Hotel. Während ein Thread 102 bucht, wartet ein anderer sogar für 201. Bei vier Zimmern ist das in Ordnung. Bei Tausenden sperrt man pro Zimmer, mit einem Lock für jedes. Der Lock ist ein RLock, weil der Thread, der ihn hält, ihn noch einmal nehmen kann. Die Übung braucht das: Der Zimmerwechsel ruft cancel und reserve auf, die ebenfalls sperren. Mit einem einfachen Lock würde der zweite Versuch ewig warten: Der Thread würde auf sich selbst warten.
  • In einem echten System liegen die Zimmer in einer Datenbank, und der Schutz passiert dort: eine Transaktion oder eine Bedingung in der Abfrage selbst (UPDATE rooms SET free = false WHERE number = 102 AND free = true). Dann gilt er auch bei mehreren Servern, nicht nur in einem Programm.

Probier es aus

Gesperrt, aber nicht ganz

Ein Kollege hat die Buchung in zwei Methoden aufgeteilt, beide mit with self._lock. Die Rezeption ruft zuerst die Prüfung auf, dann das Belegen. Bei vielen Anfragen gleichzeitig bekommen manchmal trotzdem zwei dasselbe Zimmer. In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Sieh es dir an

Das ganze Hotel

Die Testszenarien aus der Aufgabe, diesmal über das Netz, mit zwei gleichzeitig verbundenen Clients. Am Ende buchen zwei im selben Moment 201:

def say(who, client, command, text):
reply = client.send(command, text)
print(f"{who} > {command}:{text}")
print(f"{who} < {reply}")
hotel = Hotel()
hotel.add(Room(101, "Einzelzimmer", 60))
hotel.add(Room(102, "Doppelzimmer", 85))
hotel.add(Room(201, "Doppelzimmer", 95))
hotel.add(Room(202, "Suite", 140))
server = HotelServer(5343, hotel)
server.start()
anna = HotelClient("localhost", 5343)
boris = HotelClient("localhost", 5343)
say("Anna", anna, Protocol.RESERVE, "102;Anna Petrova")
say("Boris", boris, Protocol.RESERVE, "102;Boris Iliev")
say("Boris", boris, Protocol.CHECK, "102")
say("Boris", boris, Protocol.CANCEL, "102;Boris Iliev")
say("Anna", anna, Protocol.CANCEL, "102;Anna Petrova")
say("Boris", boris, Protocol.RESERVE, "102;Boris Iliev")
say("Anna", anna, Protocol.CHECK, "305")
anna.close()
boris.close()
two = ["Anna Petrova", "Vera Koleva"]
print(f"Zwei gleichzeitig für Zimmer 201, erfolgreich: {race(5343, 201, two)}")
desk = HotelClient("localhost", 5343)
print(desk.send(Protocol.FREE))
desk.close()
server.stop()
Ausgabe
Anna > BUCHEN:102;Anna Petrova
Anna < OK:Zimmer 102 gehört dir, Anna Petrova.
Boris > BUCHEN:102;Boris Iliev
Boris < FEHLER:Zimmer 102 ist schon belegt.
Boris > PRÜFEN:102
Boris < OK:102, Doppelzimmer, 85 € pro Nacht, belegt
Boris > STORNIEREN:102;Boris Iliev
Boris < FEHLER:Zimmer 102 ist von einem anderen Gast gebucht.
Anna > STORNIEREN:102;Anna Petrova
Anna < OK:Die Buchung für Zimmer 102 ist storniert.
Boris > BUCHEN:102;Boris Iliev
Boris < OK:Zimmer 102 gehört dir, Boris Iliev.
Anna > PRÜFEN:305
Anna < FEHLER:Es gibt kein Zimmer 305.
Zwei gleichzeitig für Zimmer 201, erfolgreich: 1
OK:101, Einzelzimmer, 60 € pro Nacht, frei; 202, Suite, 140 € pro Nacht, frei
Genauer, für Neugierige

Das ganze Programm, von Anfang bis Ende:

hotel.py
import socket
import threading
from concurrent.futures import ThreadPoolExecutor
class Room:
def __init__(self, number, kind, price):
self._number = number
self._kind = kind
self._price = price
self._free = True
@property
def number(self):
return self._number
@property
def kind(self):
return self._kind
@property
def price(self):
return self._price
@property
def is_free(self):
return self._free
def occupy(self):
self._free = False
def release(self):
self._free = True
def __str__(self):
state = "frei" if self._free else "belegt"
return f"{self._number}, {self._kind}, {self._price} € pro Nacht, {state}"
class Hotel:
def __init__(self):
self._rooms = []
self._guests = {}
self._lock = threading.RLock()
def add(self, room):
with self._lock:
self._rooms.append(room)
def free_rooms(self):
with self._lock:
return [room for room in self._rooms if room.is_free]
def check(self, number):
with self._lock:
return self._find(number)
def reserve(self, number, guest):
with self._lock:
if not guest.strip():
raise HotelError("Der Name des Gastes ist Pflicht.")
room = self._find(number)
if not room.is_free:
raise RoomTakenError(f"Zimmer {number} ist schon belegt.")
room.occupy()
self._guests[number] = guest
def cancel(self, number, guest):
with self._lock:
room = self._find(number)
if room.is_free:
raise HotelError(f"Zimmer {number} ist nicht gebucht.")
if self._guests[number] != guest:
raise HotelError(f"Zimmer {number} ist von einem anderen Gast gebucht.")
room.release()
del self._guests[number]
def _find(self, number):
for room in self._rooms:
if room.number == number:
return room
raise RoomNotFoundError(f"Es gibt kein Zimmer {number}.")
class HotelError(Exception):
pass
class RoomNotFoundError(HotelError):
pass
class RoomTakenError(HotelError):
pass
class Protocol:
FREE = "FREI"
CHECK = "PRÜFEN"
RESERVE = "BUCHEN"
CANCEL = "STORNIEREN"
EXIT = "ENDE"
OK = "OK"
ERROR = "FEHLER"
BYE = "TSCHÜSS"
class Reception:
def __init__(self, hotel):
self._hotel = hotel
def handle(self, line):
command, _, text = line.partition(":")
try:
match command:
case Protocol.FREE:
return f"{Protocol.OK}:{self._join(self._hotel.free_rooms())}"
case Protocol.CHECK:
return f"{Protocol.OK}:{self._hotel.check(self._number(text))}"
case Protocol.RESERVE:
return self._reserve(text)
case Protocol.CANCEL:
return self._cancel(text)
case _:
raise HotelError(f"Unbekannter Befehl {command}.")
except HotelError as e:
return f"{Protocol.ERROR}:{e}"
def _reserve(self, text):
number_text, guest = self._number_and_guest(text)
number = self._number(number_text)
self._hotel.reserve(number, guest)
return f"{Protocol.OK}:Zimmer {number} gehört dir, {guest}."
def _cancel(self, text):
number_text, guest = self._number_and_guest(text)
number = self._number(number_text)
self._hotel.cancel(number, guest)
return f"{Protocol.OK}:Die Buchung für Zimmer {number} ist storniert."
@staticmethod
def _number_and_guest(text):
parts = text.split(";", 1)
if len(parts) != 2:
raise HotelError("Schreib eine Nummer und einen Namen, zum Beispiel „102;Anna Petrova“.")
return parts
@staticmethod
def _number(text):
try:
return int(text)
except ValueError:
raise HotelError("Die Zimmernummer ist eine ganze Zahl.")
@staticmethod
def _join(rooms):
if not rooms:
return "Es gibt keine freien Zimmer."
return "; ".join(str(room) for room in rooms)
class ClientHandler:
def __init__(self, connection, reception):
self._connection = connection
self._reception = reception
def run(self):
try:
with (
self._connection,
self._connection.makefile("r", encoding="utf-8") as reader,
self._connection.makefile("w", encoding="utf-8") as writer,
):
for line in reader:
line = line.rstrip("\n")
if line == Protocol.EXIT:
break
print(self._reception.handle(line), file=writer, flush=True)
print(Protocol.BYE, file=writer, flush=True)
except OSError as e:
print(f"Die Verbindung zum Client ist abgebrochen: {e}")
class HotelServer:
def __init__(self, port, hotel):
self._server_socket = socket.create_server(("localhost", port))
self._pool = ThreadPoolExecutor(max_workers=10)
self._reception = Reception(hotel)
def start(self):
acceptor = threading.Thread(target=self._accept_all, daemon=True)
acceptor.start()
def _accept_all(self):
while True:
try:
client, _ = self._server_socket.accept()
except OSError:
return
self._pool.submit(ClientHandler(client, self._reception).run)
def stop(self):
self._server_socket.close()
self._pool.shutdown()
class HotelClient:
def __init__(self, host, port):
self._connection = socket.create_connection((host, port))
self._in = self._connection.makefile("r", encoding="utf-8")
self._out = self._connection.makefile("w", encoding="utf-8")
def send(self, command, text=None):
line = command if text is None else f"{command}:{text}"
print(line, file=self._out, flush=True)
return self._in.readline().rstrip("\n")
def close(self):
self.send(Protocol.EXIT)
self._in.close()
self._out.close()
self._connection.close()
def say(who, client, command, text):
reply = client.send(command, text)
print(f"{who} > {command}:{text}")
print(f"{who} < {reply}")
def reserve(port, room, guest):
try:
client = HotelClient("localhost", port)
reply = client.send(Protocol.RESERVE, f"{room};{guest}")
client.close()
return reply
except OSError as e:
return f"{Protocol.ERROR}:{e}"
def race(port, room, guests):
with ThreadPoolExecutor(max_workers=len(guests)) as clients:
replies = list(clients.map(lambda guest: reserve(port, room, guest), guests))
reserved = 0
for reply in replies:
if reply.startswith(Protocol.OK):
reserved += 1
return reserved
hotel = Hotel()
hotel.add(Room(101, "Einzelzimmer", 60))
hotel.add(Room(102, "Doppelzimmer", 85))
hotel.add(Room(201, "Doppelzimmer", 95))
hotel.add(Room(202, "Suite", 140))
server = HotelServer(5343, hotel)
server.start()
anna = HotelClient("localhost", 5343)
boris = HotelClient("localhost", 5343)
say("Anna", anna, Protocol.RESERVE, "102;Anna Petrova")
say("Boris", boris, Protocol.RESERVE, "102;Boris Iliev")
say("Boris", boris, Protocol.CHECK, "102")
say("Boris", boris, Protocol.CANCEL, "102;Boris Iliev")
say("Anna", anna, Protocol.CANCEL, "102;Anna Petrova")
say("Boris", boris, Protocol.RESERVE, "102;Boris Iliev")
say("Anna", anna, Protocol.CHECK, "305")
anna.close()
boris.close()
two = ["Anna Petrova", "Vera Koleva"]
print(f"Zwei gleichzeitig für Zimmer 201, erfolgreich: {race(5343, 201, two)}")
desk = HotelClient("localhost", 5343)
print(desk.send(Protocol.FREE))
desk.close()
server.stop()

Übung

Mach allein weiter

Arbeite mit dem ganzen Programm aus der vorigen Karte.

1. Zimmerwechsel. Der Gast zieht von einem Zimmer in ein anderes mit VERLEGEN:102;202;Anna Petrova. Die Antwort ist OK:Anna Petrova ist in Zimmer 202. Ist das neue Zimmer belegt, bleibt der Gast im alten. Der Wechsel ist eine Methode in Hotel, in with self._lock: Niemand darf das neue Zimmer zwischen dem Freigeben des alten und dem Belegen des neuen nehmen.

Ausgabe
OK:Zimmer 102 gehört dir, Anna Petrova.
OK:Zimmer 201 gehört dir, Boris Iliev.
FEHLER:Zimmer 201 ist schon belegt.
OK:Anna Petrova ist in Zimmer 202.
OK:101, Einzelzimmer, 60 € pro Nacht, frei; 102, Doppelzimmer, 85 € pro Nacht, frei

Weitere Ideen

  • Nächte und Summe. Eine Buchung gilt für eine Anzahl Nächte, und die Bestätigung nennt die Summe: „3 Nächte, 255 €“.
  • Daten. Ein Zimmer wird für Tage gebucht, zum Beispiel vom 14. bis 16. Dezember. Zwei Buchungen für dasselbe Zimmer dürfen sich nicht überschneiden. Ob das Zimmer frei ist, hängt von den Daten ab.
  • Meine Buchungen. MEINE:Anna Petrova sagt, welche Zimmer zu diesem Gast gehören.
  • Warteliste. Ist das Zimmer belegt, trägt sich der Gast zum Warten ein. Wird es storniert, geht es an den Ersten auf der Liste.
  • Ein Client über die Tastatur. Ein zweites Programm liest Befehle von der Tastatur mit input() und schickt sie an den Server, der in einem anderen Terminal läuft.

Test

Teste dich

1. Zwei Agenturen buchen Zimmer 102 im selben Moment, und die Buchung ist nicht synchronisiert. Was kann passieren?
2. Die Prüfung „ist es frei“ und das Belegen des Zimmers stehen in zwei getrennten synchronisierten Methoden. Reicht das?
3. Zimmer 102 ist belegt. Was bekommt der Client, der BUCHEN:102;Boris Iliev schickt?
4. Warum gibt das Programm aus, wie viele Buchungen erfolgreich waren, und nicht, wer das Zimmer bekommen hat?
5. Warum stehen die Namen der Gäste in einem Dictionary im Hotel und nicht im Zimmer?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·