El subproceso Haskell computacionalmente intensivo bloquea todos los demás subprocesos

8

Quiero escribir un programa cuyo hilo principal bifurca un nuevo hilo para el cálculo y espera a que termine por un período de tiempo. Si el hilo secundario no termina en un tiempo dado, se agota el tiempo de espera y se elimina. Tengo el siguiente código para esto.

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' 

Compilé el programa anterior con ghc -O2 Test.hsy lo ghc -O2 -threaded Test.hsejecuté, pero en ambos casos el programa simplemente se cuelga sin imprimir nada ni salir. Si agrego un threadDelay (2 * 1000 * 1000)al hilo de cálculo antes del ifbloque, el programa funciona como se esperaba y termina después de un segundo, ya que el hilo del temporizador puede llenar el mvar.

¿Por qué el enhebrado no funciona como esperaba?

Tipo al azar
fuente
Las notas MVarindican que es susceptible a las condiciones de carrera. Tomaría esa nota en serio.
Bob Dalgleish
@BobDalgleish, lo dudo mucho. La MVardisciplina me parece bien aquí.
Dfeuer
1
¿has ejecutado el programa +RTS -N? consulte wiki.haskell.org/Concurrency para obtener más información
lsmor
Aquí hay un ejemplo similar de este comportamiento y la respuesta del tipo que es el principal responsable de la mayor parte de la concurrencia en ghc, el legendario Simon :) github.com/simonmar/async/issues/93
lehins

Respuestas:

10

GHC utiliza una especie de híbrido de multitarea cooperativa y preventiva en su implementación de concurrencia.

En el nivel de Haskell, parece preventivo porque los subprocesos no necesitan ceder explícitamente y pueden verse interrumpidos por el tiempo de ejecución en cualquier momento. Pero en el nivel de tiempo de ejecución, los subprocesos "ceden" cada vez que asignan memoria. Dado que casi todos los hilos de Haskell se asignan constantemente, esto generalmente funciona bastante bien.

Sin embargo, si un cálculo en particular puede optimizarse en un código que no se asigna, puede dejar de cooperar en el nivel de tiempo de ejecución y, por lo tanto, no tener prioridad en el nivel de Haskell. Como señaló @Carl, en realidad es la -fomit-yieldsbandera, lo que implica -O2que permite que esto suceda:

-fomit-yields

Le dice a GHC que omita las comprobaciones de almacenamiento dinámico cuando no se realiza ninguna asignación. Si bien esto mejora los tamaños binarios en aproximadamente un 5%, también significa que los subprocesos que se ejecutan en bucles ajustados no asignados no se evitarán de manera oportuna. Si es importante poder interrumpir siempre dichos subprocesos, debe desactivar esta optimización. Considere también volver a compilar todas las bibliotecas con esta optimización desactivada, si necesita garantizar la interrumpibilidad.

Obviamente, en el tiempo de ejecución de un solo subproceso (sin -threadedmarca), esto significa que un subproceso puede eliminar por completo todos los demás subprocesos. Menos obvio, lo mismo puede suceder incluso si compila -threadedy usa +RTS -Nopciones. El problema es que un subproceso no cooperativo puede privar al programador de tiempo de ejecución . Si en algún momento el subproceso no cooperativo es el único subproceso programado actualmente para ejecutarse, se volverá ininterrumpible y el programador nunca se volverá a ejecutar para considerar la programación de subprocesos adicionales, incluso si pudieran ejecutarse en otros subprocesos O / S.

Si solo está tratando de probar algunas cosas, cambie la firma de fiba fib :: Integer -> Integer. Como Integercausa la asignación, todo comenzará a funcionar nuevamente (con o sin -threaded).

Si se encuentra con este problema en código real , la solución más fácil, con diferencia, es la sugerida por @Carl: si necesita garantizar la capacidad de interrupción de los subprocesos, se debe compilar el código -fno-omit-yields, lo que mantiene las llamadas del planificador en código no asignado . Según la documentación, esto aumenta los tamaños binarios; Supongo que también conlleva una pequeña penalización de rendimiento.

Alternativamente, si el cálculo ya está dentro IO, entonces explícitamente yielden el bucle optimizado puede ser un buen enfoque. Para un cómputo puro, puede convertirlo a IO y yield, aunque generalmente puede encontrar una manera simple de introducir una asignación nuevamente. En la mayoría de las situaciones realistas, habrá una manera de introducir solo unos "pocos" yieldo asignaciones, lo suficiente para que el hilo responda nuevamente, pero no lo suficiente como para afectar seriamente el rendimiento. (Por ejemplo, si tiene algunos bucles recursivos anidados, yieldo si fuerza una asignación en el bucle más externo).

KA Buhr
fuente
44
También considere compilar con -fno-omit-yields, ya que -O2implica -fomit-yields. downloads.haskell.org/~ghc/latest/docs/html/users_guide/…
Carl
¡Si! Pensé que había una bandera relevante, pero no podía recordar el nombre ni encontrarlo en el manual. En general, esta será la solución más fácil y confiable, y he actualizado mi respuesta en consecuencia.
KA Buhr