Producer und Consumer
Kurz gesagt: Producer-Consumer ist ein Muster, bei dem einige Threads Aufgaben in eine begrenzte Warteschlange legen und andere sie herausnehmen; eine volle Warteschlange lässt den Producer warten (Backpressure).
Problem
Black Friday
Um 10:00 beginnt der Sale, und die Website nimmt 50 Bestellungen pro Sekunde an. Das Lager packt 10. Es gibt drei schlechte Wege, damit umzugehen.
Die Website packt selbst. Der Kunde klickt auf „Bestellen“ und wartet, bis jemand den Karton gepackt hat. Die Website ist so schnell wie das Lager.
Die Website legt die Bestellungen in eine einfache Liste, und das Lager nimmt sie von dort. Zwei Packer können dieselbe Bestellung nehmen, und eine andere kann verloren gehen: Das ist eine Race Condition. Wenn du ein Lock hinzufügst, muss ein Packer mit leerer Liste immer wieder nachsehen, ob etwas gekommen ist.
Die Liste hat kein Limit. Die Website ist zufrieden: Sie wartet nie. Aber jede Sekunde wächst die Liste um 40 Bestellungen. Nach einer Stunde enthält sie 144.000, die letzte hat eine Stunde gewartet, und der Speicher geht aus.
Die Idee
Eine Warteschlange mit begrenztem Platz
Zwischen die beiden Seiten kommt eine Warteschlange. Der Producer legt Aufgaben hinten hinein, die Consumer nehmen sie vorn heraus. Das ist das Muster Producer und Consumer. Die beiden Seiten kennen einander nicht: Die Website weiß nicht, wie viele Packer es gibt, und die Packer wissen nicht, woher die Bestellungen kommen.
Die Warteschlange ist blockierend und hat begrenzten Platz:
- ist sie leer, wartet der Consumer, ohne den Prozessor zu beschäftigen, bis etwas kommt;
- ist sie voll, wartet der Producer, bis ein Consumer einen Platz frei macht;
- das Lock steckt in der Warteschlange: Jede Bestellung geht an genau einen Consumer.
queue.Queue(maxsize=3). put wartet, wenn sie voll ist, und get wartet, wenn sie leer ist. (Das Modul queue ist für Threads. Für async-Code gibt es auch asyncio.Queue mit denselben Methoden.)Sieh es dir an
Die Website und die Packer
DONE = None
def front(orders, queue, packers): for order in orders: queue.put(order) # wartet, wenn voll for _ in range(packers): queue.put(DONE) # eine Pille pro Packer
def pack(queue, packed): while True: order = queue.get() # wartet, wenn leer if order is DONE: return # die Pille: stopp packed.append(order)Schritt für Schritt, in Worten
- Die Website (Producer) legt Bestellungen in eine Warteschlange mit 3 Plätzen; zwei Packer (Consumer) nehmen sie vorn heraus. Sie ist leer, also warten sie in get.
- Die Website legt 1001 und 1002 hinten in die Warteschlange.
- Jeder Packer nimmt die Bestellung vorn heraus und packt sie. Das Packen ist langsamer als das Annehmen von Bestellungen.
- Die Website legt 1003, 1004 und 1005 hinein. Alle 3 Plätze sind belegt.
- 1006 passt nicht hinein: put wartet, und die Website nimmt keine Bestellungen mehr an. Das ist Backpressure: Die langsame Seite hält die schnelle zurück.
- Packer 1 ist mit 1001 fertig und nimmt 1003. Ein Platz wird frei, und das wartende put lässt 1006 hinein.
- Und so weiter: Die Packer nehmen vorn heraus, die Website fügt hinten hinzu, und in der Warteschlange sind nie mehr als 3.
- Keine Bestellungen mehr. Die Website legt nach den Bestellungen eine Pille pro Packer hinein, damit jede Bestellung vorher gepackt wird.
- Jeder Packer beendet seine Bestellung, nimmt eine Pille und hört auf. Ohne die Pille würde er ewig in get warten.
- 8 von 8, jede einmal: 3 Express für 90 €, 5 Standard für 180 €. Wer welche packt, ändert sich von Lauf zu Lauf; die Summen nicht.
Die Bestellung:
@dataclass(frozen=True)class Order: id: int euro: int express: boolDie Website und die Packer:
DONE = None
def front(orders, queue, packers): for order in orders: queue.put(order) # wartet, wenn voll for _ in range(packers): queue.put(DONE) # eine Pille pro Packer
def pack(queue, packed): while True: order = queue.get() # wartet, wenn leer if order is DONE: return # die Pille: stopp packed.append(order)front und pack laufen in drei Threads: eine Website und zwei Packer. Die Website legt die acht Bestellungen hinein und ist fertig, und die Packer nehmen, bis sie ein Zeichen zum Aufhören bekommen.orders = [ Order(1001, 25, False), Order(1002, 40, True), Order(1003, 15, False), Order(1004, 60, False), Order(1005, 30, True), Order(1006, 45, False), Order(1007, 20, True), Order(1008, 35, False),]queue = Queue(maxsize=3)packed = [] # list.append ist von vielen Threads aus sicherthreads = [threading.Thread(target=front, args=(orders, queue, 2)), threading.Thread(target=pack, args=(queue, packed)), threading.Thread(target=pack, args=(queue, packed))]for thread in threads: thread.start()for thread in threads: thread.join()report(orders, packed)Der Bericht prüft Dinge, die nicht davon abhängen, welcher Packer welche Bestellung genommen hat:
def report(orders, packed): distinct = len({order.id for order in packed}) print(f"Verpackt: {len(packed)} von {len(orders)}") print("Jede Bestellung einmal:", "ja" if distinct == len(orders) else "nein") for express in (True, False): group = [order for order in packed if order.express == express] kind = "Express" if express else "Standard" print(f"{kind}: {len(group)} Bestellungen, {sum(o.euro for o in group)} €")Verpackt: 8 von 8Jede Bestellung einmal: jaExpress: 3 Bestellungen, 90 €Standard: 5 Bestellungen, 180 €Die Ausgabe ist bei jedem Lauf dieselbe. Wie viele Bestellungen jeder Packer genommen hat, ändert sich, deshalb gibt das Programm es nicht aus.
Die Idee
Wie die Packer aufhören
Der Packer wartet in get(), bis eine Bestellung kommt. Wenn die Website einfach endet, warten die Packer ewig, und das Programm endet nie.
Deshalb legt die Website am Ende eine Giftpille (Poison Pill) hinein: einen besonderen Wert, der keine Bestellung ist. Ein Packer, der die Pille nimmt, hört auf. Es gibt eine Pille pro Packer: Jeder nimmt nur eine und legt sie nicht zurück.
Die Pillen kommen nach den Bestellungen in die Warteschlange, damit jede Bestellung vor ihnen gepackt wird.
Hier ist die PilleDONE = None, und is DONE vergleicht sie nach Identität.Die Idee
Backpressure
Eine volle Warteschlange lässt die Website warten. Das ist kein Fehler, das ist der Sinn: Die langsame Seite drückt zurück auf die schnelle. Das heißt Backpressure. Die Website kann dem Lager höchstens um die Größe der Warteschlange voraus sein, der Speicher wächst nicht, und die letzte Bestellung wartet höchstens so lange, wie das Packen der 3 vor ihr dauert.
Warten ist nicht die einzige Antwort auf eine volle Warteschlange. Es gibt auch eine Methode, die nicht wartet, sondern sagt, ob es geklappt hat:
put_nowait(order) löst queue.Full aus, wenn kein Platz da ist. put(order, timeout=1) wartet höchstens eine Sekunde und löst dann Full aus.Damit kann die Website „Hohe Nachfrage, versuch es in einer Minute noch einmal“ anzeigen, statt zu hängen. Wichtig ist, dass die Entscheidung bei dir liegt: Die Bestellung verschwindet nicht stillschweigend.
Probier es aus
Sag die Ausgabe voraus
Eine Warteschlange mit 2 Plätzen und ohne Packer. Was gibt sie aus?
def offer(queue, item): try: queue.put_nowait(item) return True except Full: return False
queue = Queue(maxsize=2)print(offer(queue, "1001"))print(offer(queue, "1002"))print(offer(queue, "1003"))print(queue.get())print(offer(queue, "1003"))print(queue.qsize())Die ersten beiden kommen hinein. Für die dritte ist kein Platz, und die Methode, die nicht wartet, gibt „nein“ zurück. get() nimmt die erste, die hineingekommen ist: 1001. Jetzt ist ein Platz frei, und 1003 kommt hinein. Darin liegen 1002 und 1003: 2.
Probier es aus
Ergänze
for _ in range():
queue.put(DONE)packers: eine Pille für jeden Packer. Mit weniger wartet ein Packer ewig; mit mehr bleiben die überzähligen Pillen einfach in der Warteschlange.Probier es aus
Finde den Fehler
Ein Kollege will nicht, dass die Website wartet, und ändert eine Zeile. Jetzt hängt das Programm nie mehr, aber der Bericht sagt manchmal „Verpackt: 5 von 8“. In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
Ist die Warteschlange voll, wird die Bestellung übersprungen. (Außerdem sind „prüfen, dann hineinlegen“ zwei Schritte: Dazwischen kann die Warteschlange voll werden.) Die Lösung ist put ohne die Prüfung, oder, wenn es wirklich nicht warten darf, das Ergebnis zu prüfen und den Kunden wissen zu lassen, dass die Bestellung nicht angenommen wurde.
Sieh es dir an
Threads, Worker und Nachrichten
Queue() ohne maxsize hat kein Limit: put wartet nie, und das Problem vom Anfang der Lektion ist zurück. In async-Code ist die Warteschlange asyncio.Queue(maxsize=3) mit await queue.put(...) und await queue.get(): dasselbe, aber in einem Thread.
Übung
Zwei Eingänge, drei Packer
Löse die Aufgabe in deinem Editor.
Bestellungen kommen aus zwei Eingängen: der Website (2001–2006) und der Mobile-App (3001–3006). Jeder Eingang ist ein eigener Producer, und es gibt drei Packer. Die Warteschlange hat 4 Plätze. Die Pillen (eine pro Packer) kommen erst, wenn beide Eingänge fertig sind. Gib aus:
Verpackt: 12 von 12Jede Bestellung einmal: jaGesamt: 325 €Weitere Aufgaben
- Ablehnung. Mach die Warteschlange 1 Platz groß und lass die Website
put(order, timeout=0.01)benutzen. Zähle die abgelehnten Bestellungen und gib „angenommen + abgelehnt = 12“ aus. - Nach Art. Express-Bestellungen müssen zuerst gepackt werden. Hilft queue.PriorityQueue? Was passiert mit den Pillen?
- Eine Kette. Füge nach den Packern eine zweite Warteschlange hinzu: Sie legen die gepackten Kartons hinein, und ein Kurier nimmt sie heraus. Wie viele Pillen braucht man jetzt, und wer legt sie hinein?
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Race Condition · Sperren · Atomare Operationen und unveränderliche Objekte · Deadlock
- Geht weiter in: Thread-Pool und Aufgaben · Ereignisse und Warteschlangen
- Aufgaben zum Thema: Nebenläufigkeit und Race Conditions