Cosa c’è di male in Lazy I / O?

In generale ho sentito che il codice di produzione dovrebbe evitare di usare Lazy I / O. La mia domanda è, perché? È mai OK usare Lazy I / O al di fuori del semplice giocherellando? E cosa rende migliori le alternative (ad esempio gli enumeratori)?

    Lazy IO ha il problema che rilasciare qualsiasi risorsa che si è acquisita è alquanto imprevedibile, in quanto dipende dal modo in cui il programma consuma i dati – il suo “modello di domanda”. Una volta che il tuo programma lascia l’ultimo riferimento alla risorsa, alla fine il GC funzionerà e rilascerà quella risorsa.

    I flussi pigri sono uno stile molto conveniente da programmare. Ecco perché i tubi shell sono così divertenti e popolari.

    Tuttavia, se le risorse sono limitate (come in scenari ad alte prestazioni o in ambienti di produzione che prevedono di scalare fino ai limiti della macchina) affidarsi al GC per ripulire può essere una garanzia insufficiente.

    A volte devi rilasciare le risorse con entusiasmo, al fine di migliorare la scalabilità.

    Quindi quali sono le alternative a IO pigro che non significano rinunciare all’elaborazione incrementale (che a sua volta consumerebbe troppe risorse)? Bene, abbiamo foldl basata su foldl , ovvero iterate o enumeratori, introdotta da Oleg Kiselyov alla fine degli anni 2000 , e da allora resa popolare da una serie di progetti basati sulla rete.

    Invece di elaborare i dati come flussi pigri, o in un unico lotto enorme, ci astraggono invece sull’elaborazione rigorosa basata su blocchi, con la finalizzazione garantita della risorsa una volta letto l’ultimo blocco. Questa è l’essenza della programmazione basata su iteratee, che offre vincoli di risorse molto interessanti.

    Il lato negativo dell’IO basato su iteratee è che ha un modello di programmazione un po ‘scomodo (approssimativamente analogo alla programmazione basata su eventi, rispetto al controllo basato su thread). È sicuramente una tecnica avanzata, in qualsiasi linguaggio di programmazione. E per la stragrande maggioranza dei problemi di programmazione, l’IO pigro è del tutto soddisfacente. Tuttavia, se aprirai molti file, o parlerai su molti socket, o altrimenti utilizzerai molte risorse simultanee, un approccio iterativo (o enumeratore) potrebbe avere senso.

    Dons ha fornito un’ottima risposta, ma ha trascurato quella che è (per me) una delle caratteristiche più interessanti degli iterate: rendono più facile ragionare sulla gestione dello spazio perché i vecchi dati devono essere mantenuti esplicitamente. Prendere in considerazione:

     average :: [Float] -> Float average xs = sum xs / length xs 

    Questa è una perdita di spazio ben nota, perché l’intera lista xs deve essere conservata in memoria per calcolare sia la sum che la length . È ansible creare un consumatore efficiente creando una piega:

     average2 :: [Float] -> Float average2 xs = uncurry (/) < $> foldl (\(sumT, n) x -> (sumT+x, n+1)) (0,0) xs -- NB this will build up thunks as written, use a strict pair and foldl' 

    Ma è un po ‘scomodo doverlo fare per ogni processore di streaming. Ci sono alcune generalizzazioni ( Conal Elliott – Beautiful Fold Zipping ), ma non sembrano aver preso piede. Tuttavia, le iterazioni possono darti un livello di espressione simile.

     aveIter = uncurry (/) < $> I.zip I.sum I.length 

    Questo non è efficiente come una piega perché l’elenco è ancora ripetuto più volte, ma è raccolto in blocchi in modo che i vecchi dati possano essere raccolti in modo efficiente. Per rompere quella proprietà, è necessario mantenere esplicitamente l’intero input, come con stream2list:

     badAveIter = (\xs -> sum xs / length xs) < $> I.stream2list 

    Lo stato di iteratees come modello di programmazione è un work in progress, tuttavia è molto meglio di un anno fa. Stiamo imparando quali combinatori sono utili (ad es. zip , breakE , enumWith ) e meno, con il risultato che le iterate e i combinatori incorporati forniscono sempre più espressività.

    Detto questo, Dons ha ragione nel dire che sono una tecnica avanzata; Io certamente non li userei per ogni problema di I / O.

    Io uso lazy I / O nel codice di produzione tutto il tempo. È solo un problema in certe circostanze, come ha detto Don. Ma per la semplice lettura di alcuni file funziona bene.

    Un altro problema con IO pigro che non è stato menzionato finora è che ha un comportamento sorprendente. In un normale programma Haskell, a volte può essere difficile prevedere quando ogni parte del programma viene valutata, ma fortunatamente a causa della purezza non importa se non si verificano problemi di prestazioni. Quando viene introdotto un IO pigro, l’ordine di valutazione del tuo codice ha effettivamente un effetto sul suo significato, quindi i cambiamenti a cui sei abituato a pensare come innocui possono causare problemi reali.

    Ad esempio, ecco una domanda sul codice che sembra ragionevole ma è resa più confusa da IO differito: withFile vs. openFile

    Questi problemi non sono invariabilmente fatali, ma è un’altra cosa a cui pensare, e un mal di testa sufficientemente severo che io personalmente evito IO pigro a meno che non ci sia un vero problema nel fare tutto il lavoro in anticipo.

    Aggiornamento: Recentemente su haskell-cafe Oleg Kiseljov ha mostrato che unsafeInterleaveST (che è usato per implementare IO pigro all’interno della monade ST) è molto pericoloso – rompe il ragionamento equo. Mostra che permette di build bad_ctx :: ((Bool,Bool) -> Bool) -> Bool tale che

     > bad_ctx (\(x,y) -> x == y) True > bad_ctx (\(x,y) -> y == x) False 

    anche se == è commutativo.


    Un altro problema con IO pigro: l’operazione IO effettiva può essere posticipata fino a quando non è troppo tardi, ad esempio dopo che il file è stato chiuso. Citando da Haskell Wiki – Problemi con IO pigro :

    Ad esempio, un errore principiante comune è quello di chiudere un file prima che uno abbia finito di leggerlo:

     wrong = do fileData < - withFile "test.txt" ReadMode hGetContents putStr fileData 

    Il problema è con File chiude l'handle prima che fileData sia forzato. Il modo corretto è quello di passare tutto il codice a withFile:

     right = withFile "test.txt" ReadMode $ \handle -> do fileData < - hGetContents handle putStr fileData 

    Qui, i dati vengono consumati prima con le finiture File.

    Questo è spesso inaspettato e un errore facile da fare.


    Vedi anche: Tre esempi di problemi con Lazy I / O.