O que há de tão ruim no Lazy I / O?

89

Eu geralmente ouvi que o código de produção deve evitar o uso de E / S lenta. Minha pergunta é, por quê? É normal usar o Lazy I / O fora de apenas brincar? E o que torna as alternativas (por exemplo, enumeradores) melhores?

Dan Burton
fonte

Respostas:

81

O Lazy IO tem o problema de liberar qualquer recurso que você adquiriu seja um tanto imprevisível, pois depende de como seu programa consome os dados - seu "padrão de demanda". Uma vez que seu programa elimine a última referência ao recurso, o GC irá eventualmente executar e liberar esse recurso.

Streams lentos são um estilo muito conveniente de programar. É por isso que os shell pipes são tão divertidos e populares.

No entanto, se os recursos forem limitados (como em cenários de alto desempenho ou ambientes de produção que esperam escalar até os limites da máquina), contar com o GC para limpar pode ser uma garantia insuficiente.

Às vezes, você precisa liberar recursos avidamente, a fim de melhorar a escalabilidade.

Então, quais são as alternativas para IO preguiçoso que não significa desistir do processamento incremental (que por sua vez consumiria muitos recursos)? Bem, temos o foldlprocessamento baseado, também conhecido como iteratees ou enumeradores, introduzido por Oleg Kiselyov no final dos anos 2000 e, desde então, popularizado por uma série de projetos baseados em rede.

Em vez de processar dados como fluxos lazy, ou em um lote enorme, em vez disso, abstraímos o processamento estrito baseado em chunk, com finalização garantida do recurso assim que o último chunk for lido. Essa é a essência da programação baseada em iteratee, que oferece ótimas restrições de recursos.

A desvantagem do IO baseado em iteratee é que ele tem um modelo de programação um tanto estranho (aproximadamente análogo à programação baseada em eventos, versus um bom controle baseado em thread). É definitivamente uma técnica avançada, em qualquer linguagem de programação. E para a grande maioria dos problemas de programação, IO preguiçoso é inteiramente satisfatório. No entanto, se você for abrir muitos arquivos, ou falar em muitos sockets, ou usar muitos recursos simultâneos, uma abordagem iteratária (ou enumerador) pode fazer sentido.

Don Stewart
fonte
22
Como acabei de seguir um link para esta velha questão de uma discussão sobre I / O preguiçoso, pensei em acrescentar uma observação que, desde então, muito da estranheza dos iteratees foi supervisionada por novas bibliotecas de streaming, como pipes e conduítes .
Ørjan Johansen
40

Dons forneceu uma resposta muito boa, mas deixou de fora o que é (para mim) uma das características mais atraentes dos iteratees: eles tornam mais fácil raciocinar sobre o gerenciamento do espaço porque os dados antigos devem ser retidos explicitamente. Considerar:

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

Este é um vazamento de espaço bem conhecido, porque a lista inteira xsdeve ser retida na memória para calcular sume length. É possível tornar um consumidor eficiente criando uma dobra:

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

Mas é um tanto inconveniente ter que fazer isso para todos os processadores de fluxo. Existem algumas generalizações ( Conal Elliott - Beautiful Fold Zipping ), mas elas não parecem ter pegado. No entanto, os iteratees podem obter um nível semelhante de expressão.

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

Isso não é tão eficiente quanto uma dobra porque a lista ainda é iterada várias vezes; no entanto, é coletada em partes para que os dados antigos possam ser coletados de forma eficiente como lixo. Para quebrar essa propriedade, é necessário reter explicitamente toda a entrada, como com stream2list:

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

O estado dos iteratees como um modelo de programação é um trabalho em andamento, no entanto, é muito melhor do que há um ano. Nós estamos aprendendo o que combinators são úteis (por exemplo zip, breakE, enumWith) e que são menos assim, com o resultado que built-in iteratees e combinadores fornecer continuamente mais expressividade.

Dito isso, Dons está correto ao dizer que eles são uma técnica avançada; Eu certamente não os usaria para todos os problemas de E / S.

John L
fonte
25

Eu uso I / O lento no código de produção o tempo todo. É apenas um problema em certas circunstâncias, como Don mencionou. Mas para apenas ler alguns arquivos funciona bem.

agosto
fonte
Eu uso I / O preguiçoso também. Recorro aos iteratees quando desejo mais controle sobre o gerenciamento de recursos.
John L
20

Atualização: Recentemente, no haskell-cafe, Oleg Kiseljov mostrou que unsafeInterleaveST(que é usado para implementar IO preguiçoso dentro da mônada ST) é muito inseguro - quebra o raciocínio equacional. Ele mostra que permite construir de bad_ctx :: ((Bool,Bool) -> Bool) -> Bool tal forma que

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

embora ==seja comutativo.


Outro problema com o IO lento: a operação real do IO pode ser adiada até que seja tarde demais, por exemplo, depois que o arquivo for fechado. Citando de Haskell Wiki - Problemas com IO preguiçoso :

Por exemplo, um erro comum de iniciante é fechar um arquivo antes de terminar de lê-lo:

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

O problema é que withFile fecha o identificador antes que fileData seja forçado. A maneira correta é passar todo o código para withFile:

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

Aqui, os dados são consumidos antes que withFile termine.

Isso geralmente é inesperado e um erro fácil de cometer.


Consulte também: Três exemplos de problemas com E / S lenta .

Petr Pudlák
fonte
Na verdade, combinar hGetContentse withFileé inútil porque o primeiro coloca o identificador em um estado "pseudo-fechado" e fará o fechamento para você (preguiçosamente), de modo que o código é exatamente equivalente ao readFile, ou mesmo openFilesem hClose. Isso é basicamente o preguiçoso I / O é . Se você não usar readFile, getContentsou hGetContentsvocê não está usando preguiçoso I / O. Por exemplo, line <- withFile "test.txt" ReadMode hGetLinefunciona bem.
Dag
1
@Dag: embora trate de hGetContentsfechar o arquivo para você, também é permitido fechá-lo "mais cedo" e ajuda a garantir que os recursos sejam liberados de maneira previsível.
Ben Millwood
17

Outro problema com IO preguiçoso que não foi mencionado até agora é que ele tem um comportamento surpreendente. Em um programa Haskell normal, às vezes pode ser difícil prever quando cada parte do programa é avaliada, mas felizmente, devido à pureza, isso realmente não importa, a menos que você tenha problemas de desempenho. Quando o IO preguiçoso é introduzido, a ordem de avaliação do seu código realmente tem um efeito sobre seu significado, portanto, as alterações que você está acostumado a considerar inofensivas podem causar problemas genuínos.

Por exemplo, aqui está uma pergunta sobre o código que parece razoável, mas fica mais confusa pelo IO adiado: withFile vs. openFile

Esses problemas não são invariavelmente fatais, mas é outra coisa a se pensar, e uma dor de cabeça suficientemente forte que eu pessoalmente evito IO preguiçoso, a menos que haja um problema real em fazer todo o trabalho antecipadamente.

Ben Millwood
fonte