Haskell rechenintensiver Thread blockiert alle anderen Threads

8

Ich möchte ein Programm schreiben, dessen Hauptthread einen neuen Thread zur Berechnung gibt und darauf wartet, dass er eine Zeit lang beendet wird. Wenn der untergeordnete Thread nicht in einer bestimmten Zeit beendet wird, wird eine Zeitüberschreitung festgestellt und beendet. Ich habe den folgenden Code dafür.

import Control.Concurrent

fibs :: Int -> Int 
fibs 0 = 0
fibs 1 = 1
fibs n = fibs (n-1) + fibs (n-2)

main = do 
    mvar  <- newEmptyMVar 
    tid   <- forkIO $ do
        threadDelay (1 * 1000 * 1000)
        putMVar mvar Nothing 
    tid'  <- forkIO $ do
        if fibs 1234 == 100
            then putStrLn "Incorrect answer" >> putMVar mvar (Just False)
            else putStrLn "Maybe correct answer" >> putMVar mvar (Just True)
    putStrLn "Waiting for result or timeout"
    result <- takeMVar mvar
    killThread tid
    killThread tid' 

Ich habe das obige Programm mit ghc -O2 Test.hsund kompiliert ghc -O2 -threaded Test.hsund ausgeführt, aber in beiden Fällen hängt das Programm nur, ohne etwas zu drucken oder zu beenden. Wenn ich threadDelay (2 * 1000 * 1000)dem Berechnungsthread vor dem ifBlock ein hinzufüge , funktioniert das Programm wie erwartet und endet nach einer Sekunde, da der Timer-Thread den füllen kann mvar.

Warum funktioniert das Threading nicht wie erwartet?

Zufälliger Typ
quelle
Die Hinweise auf MVarbesagen, dass es anfällig für Rennbedingungen ist. Ich würde diese Notiz ernst nehmen.
Bob Dalgleish
@ BobDalgleish, ich bezweifle es sehr. Die MVarDisziplin sieht für mich hier gut aus.
dfeuer
1
Hast du das Programm mit ausgeführt +RTS -N? überprüfen wiki.haskell.org/Concurrency für weitere Informationen
lsmor
Hier ist ein ähnliches Beispiel für dieses Verhalten und die Reaktion des Mannes, der hauptsächlich für den größten Teil der Parallelität in ghc verantwortlich ist, des legendären Simon :) github.com/simonmar/async/issues/93
lehins

Antworten:

10

GHC verwendet bei seiner Parallelitätsimplementierung eine Art Hybrid aus kooperativem und präventivem Multitasking.

Auf der Haskell-Ebene scheint dies präventiv zu sein, da Threads nicht explizit nachgeben müssen und jederzeit durch die Laufzeit unterbrochen werden können. Auf Laufzeitebene "geben" Threads jedoch immer dann nach, wenn sie Speicher zuweisen. Da fast alle Haskell-Threads ständig zugeordnet werden, funktioniert dies normalerweise recht gut.

Wenn eine bestimmte Berechnung jedoch in nicht zuweisenden Code optimiert werden kann, kann sie auf Laufzeitebene nicht mehr kooperativ und auf Haskell-Ebene nicht mehr vorab zulässig sein. Wie @Carl betonte, ist es tatsächlich die -fomit-yieldsFlagge, die impliziert wird -O2, dass dies möglich ist:

-fomit-yields

Weist GHC an, Heap-Prüfungen wegzulassen, wenn keine Zuordnung durchgeführt wird. Dies verbessert zwar die Binärgröße um etwa 5%, bedeutet jedoch auch, dass Threads, die in engen, nicht zuweisenden Schleifen ausgeführt werden, nicht rechtzeitig vorab geprüft werden. Wenn es wichtig ist, solche Threads immer unterbrechen zu können, sollten Sie diese Optimierung deaktivieren. Ziehen Sie auch in Betracht, alle Bibliotheken mit deaktivierter Optimierung neu zu kompilieren, wenn Sie die Unterbrechbarkeit gewährleisten möchten.

In der Single-Threaded-Laufzeit (kein -threadedFlag) bedeutet dies natürlich, dass ein Thread alle anderen Threads vollständig aushungern kann. Weniger offensichtlich kann dasselbe passieren, selbst wenn Sie mit Optionen kompilieren -threadedund diese verwenden +RTS -N. Das Problem ist , dass ein unkooperativ Thread die Laufzeit aushungern kann Scheduler selbst. Wenn der nicht kooperative Thread irgendwann der einzige Thread ist, dessen Ausführung derzeit geplant ist, wird er nicht mehr unterbrochen, und der Scheduler wird niemals erneut ausgeführt, um das Planen zusätzlicher Threads in Betracht zu ziehen, selbst wenn sie auf anderen Betriebssystem-Threads ausgeführt werden könnten.

Wenn Sie nur versuchen, einige Dinge zu testen, ändern Sie die Signatur von fibin fib :: Integer -> Integer. Da Integerdie Zuordnung verursacht, funktioniert alles wieder (mit oder ohne -threaded).

Wenn Sie in echtem Code auf dieses Problem stoßen, ist die mit Abstand einfachste Lösung die von @Carl vorgeschlagene: Wenn Sie die Unterbrechbarkeit von Threads gewährleisten müssen, sollte der Code mit kompiliert werden -fno-omit-yields, wodurch Scheduler-Aufrufe in nicht zuweisendem Code erhalten bleiben . Gemäß der Dokumentation werden dadurch die Binärgrößen erhöht. Ich gehe davon aus, dass es auch eine kleine Leistungsstrafe gibt.

Wenn die Berechnung bereits ausgeführt wird IO, yieldkann es auch sinnvoll sein , explizit in der optimierten Schleife zu arbeiten. Für eine reine Berechnung können Sie sie in E / A konvertieren, und yieldobwohl Sie normalerweise eine einfache Möglichkeit finden, eine Zuordnung erneut einzuführen. In den meisten realistischen Situationen gibt es eine Möglichkeit, nur "wenige" yieldoder Zuordnungen einzuführen - genug, um den Thread wieder ansprechbar zu machen, aber nicht genug, um die Leistung ernsthaft zu beeinträchtigen. (Wenn Sie beispielsweise verschachtelte rekursive Schleifen haben yieldoder eine Zuweisung in der äußersten Schleife erzwingen.)

KA Buhr
quelle
4
Erwägen Sie auch das Kompilieren mit -fno-omit-yields, da dies -O2impliziert -fomit-yields. downloads.haskell.org/~ghc/latest/docs/html/users_guide/…
Carl
Ja! Ich dachte, es gäbe eine relevante Flagge, aber ich konnte mich nicht an den Namen erinnern oder ihn im Handbuch finden. Dies ist im Allgemeinen die einfachste und zuverlässigste Lösung, und ich habe meine Antwort entsprechend aktualisiert.
KA Buhr