Concorrência básica em Haskell: Node.js, IO, green-threads e promessas

Volta e meia gosto de tentar encontrar “flame-wars” na internet sobre uma ou outra plataforma da qual gosto. Não me entenda mal; o tópico de se uma linguagem é melhor que a outra não traz benefícios e não faz sentido para mim. No final, você deve usar o que se sente mais confortável com e para projetos reais deve considerar além da tecnologia: o ecossistema, a piscina de talento, o suporte comercial etc. Mesmo assim, fazer essas buscas tarde da noite e (até agora) secretamente, rendeu-me boas leituras.

Tais como Simon Marlow dizendo algumas verdades sobre o Erlang e OTP contra Haskell. Em geral as boas respostas são conciliadoras e sinceras. Use a ferramenta certa para seus problemas.

Ainda assim há algum tempo encontrei essa pequena pergunta no StackOverflow:

A pergunta é mal embasada, mas fico feliz que ela tenha sido feita, porque também foi respondida pelo Simon Marlow. A resposta foi em essência: “não há uma resposta para o Node.js, porque Haskell também suporta IO assíncrono e com mais estas e aquelas características benéficas”.

Deixando claro que adoro Node.js; acho que é uma ótima ferramenta com sua série de benefícios.

Gostaria de dar um gostinho de como a linguagem de programação Haskell lida com concorrência, algo que brilha enormemente em fazer.

Estas e aquelas características benéficas

Esse curto post vai introduzir alguns conceitos de concorrência em Haskell. Ele é destinado a programadores que:

  • Nunca viram Haskell
  • Não sabem como Haskell resolve a problemática de escrever programas concorrentes

Infelizmente, há padrões e conceitos que não vão caber aqui e que duvido conseguir explicar de maneira melhor do que a literatura existente. O livro "Parallel and Concurrent Programming in Haskell" é distribuído gratuitamente na internet. É um livro curto e muito recompensador.

Ainda assim, espero conseguir dar um overview de como a linguagem lida com concorrência e meu objetivo é menos que você seja capaz de escrever Haskell concorrente após ler esse texto e mais que entenda o que é Concorrência básica em Haskell.

Concorrência e Paralelismo

Cito o livro “Parallel and Concurrent Programming in Haskell” que faz a distinção muito bem:

Um programa paralelo é um programa que usa a multiplicidade de hardware computacional [...] para realizar uma computação mais rápido. [...]

Em contraste, concorrência é uma técnica de estruturação de programas onde há muitos contextos de controle.

Haskell brilha em ambas as tarefas e esse livro citado é separado em duas partes tratando cada uma delas. O post trata da segunda.

O tipo IO

Haskell é uma linguagem pura. O que quer dizer que todos os “efeitos” de uma função devem estar isolados. Fazemos isso com um tipo especial para todas as computações que causam efeitos.

Podemos dizer, por exemplo, que a expressão:

 10 + 10

tem tipo:

 Int

Se quisermos definir uma função soma10 escrevemos:

soma10 x = x + 10

Essa função teria tipo:

Int -> Int

Ela recebe um Int e retorna outro Int.

O que faz Haskell ser “puro” é que essa função soma10 não pode causar efeitos. Se tentarmos imprimir alguma coisa no terminal, fazer um request HTTP ou lançar um míssil nessa função, o compilador vai reclamar e nosso programa não dará “type-check”.

Aí é que entra o tipo IO. O tipo IO é um tipo especial que representa uma ação ou sequência de ações que causam efeitos. Além de causar que todos o estado e efeitos de um programa fiquem completamente isolados de coisas como a implementação de algoritmos ou de regras de negócio, uma ação ou sequência de ações podem ter um resultado (quando leio uma linha do terminal, espero uma string).

Por isso o tipo IO na verdade é IO algumTipo o que quer dizer: uma ação que causa efeitos e retorna um valor de algumTipo. Ler uma string do terminal seria feito com a função getLine que tem tipo IO String. É uma ação que lê uma String do stdin.

Se a ação não resultar em nada (quando imprimo uma string no terminal não espero nada), dizemos que retorna o tipo vazio (). É só outro nome para void, representado por uma "tupla" de zero valores. Assim, imprimir uma string no terminal seria feito com a função putStrLn que tem tipo String -> IO (). Uma ação que imprime uma String no stdin.

Direto ao Haskell avançado: green-threads e callback-hell

O suporte a concorrência em Haskell é feito usando a abstração de green-threads. O que isso quer dizer é que o modelo mental que uso para programar em Haskell é que posso dizer para o runtime que quero rodar em outro contexto da CPU, mas na verdade não estou criando uma thread do sistema operacional. É possível criar milhares dessas "threads" em um único programa. Esse é o modelo base usado na linguagem de programação Go com suas goroutines. E também é conhecido como Fibers.

A ideia é que quanto mais granular for a estrutura do trabalho que quero distribuir entre CPUs, mais facilmente vou conseguir deixar todos os cores da máquina saturados. Isso é algo especialmente importante, quando pensando que a tendência é que processadores tenham mais e mais cores.

No Haskell criamos uma nova green-thread com a função forkIO. Ela tem tipo IO () -> IO ThreadId, o que significa que recebe uma ação IO que causa efeitos e não retorna nada e retorna um ID para a thread que roda nossa ação. Quando chamamos forkIO umaAcao, uma green-thread é criada para executar umaAcao. Podemos então usar o ID retornado para fazer coisas como terminar a thread:

tid <- forkIO lancaMisseis
killThread tid

Isso é tudo o que precisamos para escrever um programa usando continuation-passing style ou para pessoas que programam em Node.js, I/O assíncrono com callbacks. Incrível certo? O runtime do Haskell tem um scheduler extremamente sofisticado que distribuí o trabalho de todas as "threads" que criamos na linguagem em threads reais do sistema operacional. Com a diferença de que porque todo os efeitos estão isolados, o runtime tem muito mais espaço para potencialmente otimizar as tarefas.

Digamos que tenhamos uma função fazRequestsHTTP, com tipo:

Callback -> IO ()

Onde Callback é uma função que recebe a resposta como uma String e executa uma ação. Callback então teria tipo:

String -> IO ()

O tipo completo de fazRequestsHTTP seria:

(String -> IO ()) -> IO ()

Se escrever:

-- Todo programa em Haskell tem uma função main
main = do
-- Definimos um callback
let callback r = putStrLn ("callback: " ++ r)
-- Começamos nosso thread
forkIO (fazRequestsHTTP callback)

-- Essa linha vai ser impressa imediatamente
putStrLn "Eu apareço antes do callback ser executado!"
threadDelay 1000000000

Ao rodar esse programa vejo:

[http] Fetching google.com
Eu apareço antes do callback ser executado!
[http] Fetching google.com/something
callback: <html>…</html>

Uma definição para fazRequestsHTTP seria:

fazRequestsHTTP cb = do
putStrLn "[http] Fetching google.com"
threadDelay 1000
putStrLn "[http] Fetching google.com/something"
threadDelay 1000
-- Chama o callback com o resultado
cb "<html>...</html>"

Eu introduzi alguns conceitos Haskell nesse trecho, mas não acho que vale a pena discutir a sintaxe. Algo mais importante do que isso é o que fazemos no final do trecho, chamando threadDelay. Fazemos isso porque, diferente do que se tem em Node.js, ao rodar esse programa, o runtime não espera todas as "threads" que criamos terminarem de executar. Isso fica na nossa mão. Além disso eu simplifiquei o programa real. Veja o código que pode ser copiado e colado para seu terminal aqui.

As funções forkIO e threadDelay vem de um módulo na biblioteca padrão chamado Control.Concurrent. O que quero mostrar é que esse módulo oferece primitivas para uma série de estilos de programação. O primeiro vimos aqui e levou pouquíssimo para fazer funcionar (só uma função da biblioteca padrão!), foi o continuation-passing style popular nas bibliotecas padrão do Node.js.

Podemos fazer muito melhor.

Dividindo estado entre threads

Outra primitiva fundamental que Control.Concurrent oferece são as MVars. A ideia por traz delas é ter uma caixinha (container) para o estado mutável dividido entre threads, que respeita algumas propriedades importantes.

Se você está pensando "como assim? Haskell não era uma linguagem sem variáveis?" ou "mas isso não pode dar dead-lock?", as respostas são "sim" e "sim". Vamos ver padrões melhores em outro post, mas minha explicação para isso é que você pode escrever Haskell da forma que quiser; pode chamar C ou escrever um programa que se comporta de forma muito parecida a um programa escrito em C, nem precisa usar uma primitiva tão sofisticada quanto as MVars que vou apresentar. Haskell tem variáveis, elas só não são usadas a maior parte do tempo.

Mas há alguns pontos que fazem lidar com MVars mais fácil do que com um pedaço de memória compartilhado em linguagens imperativas.

Crio uma nova MVar com:

caixinha <- newEmptyMVar

Essa função newEmptyMVar tem tipo:

IO (MVar algumTipo)

É uma ação que causa efeitos e retorna uma MVar destinada a conter um valor de algumTipo.

Uma MVar pode ter dois estados: pode estar vazia ou cheia, contendo um elemento de algumTipo. Se ela estiver cheia e uma thread tentar botar um valor dentro dela, meu thread vai estar bloqueado até outro thread vir e a esvaziar. E o contrário, se ela estiver vazia e uma thread tentar tirar um valor dela, meu thread vai estar bloqueado até outro thread vir e a encher.

Isso pode dar dead-locks, mas há primitivas na biblioteca padrão para evitar que isso aconteça. Uma descrição delas não cabe aqui, mas pense em como o Redis, por exemplo, oferece operações atômicas sobre chaves e valores.

Criamos uma MVar com newEmptyMVar e agora podemos a encher com putMVar:

putMVar caixinha "um valor"

E podemos a esvaziar com takeMVar:

valor <- takeMVar caixinha
print valor -- => "um valor"

Talvez não seja absolutamente claro o que essa primitiva nos oferece. Mesmo porque há muitos padrões que podem ser construídos a partir dela. Podemos usar ela como um "lock":

pegaOLock = takeMVar mvarGlobal
liberaOLock = putMVar mvarGlobal ()

Ou como uma forma de dividir estado:

-- * um banco é só uma lista de saldos
-- * nosso programa opera com o estado em uma MVar
-- contendo uma dessas listas
-- * o índice de um saldo é seu ID
depositaDinheiroNaConta mvarDoBanco quantia idAlvo = do
banco <- takeMVar mvarDoBanco
putMVar mvarDoBanco (map helper (indexed banco))
where
helper (idAtual, saldo) = if idAtual == idAlvo
then saldo + quantia
else quantia
indexed lista = zip lista [0..]

Podemos ter duas threads executando essa rotina com uma tranca implícita na mvarDoBanco. Enquanto uma altera o saldo, outra não pode ler o estado e fazer alguma coisa. De maneira geral, para esse tipo de uso, não é incomum que todo o estado do programa esteja centralizado em uma única MVar e que só uma thread o possa acessar. Partes menores podem ter seus próprios padrões de concorrência, mas as regras de negócio não espalham estado por aí.

Há muitos outros usos que não cabem nesse artigo, mas o uso específico que gostaria de discutir a fundo é a coordenação simples de duas threads. Ao invés de passar um callback para a rotina que quero executar de forma assíncrona, passo uma MVar vazia para ela e tento a ler. A thread que lê a MVar vai então estar bloqueada até a thread de fundo terminar de executar e encher a MVar com o valor. E isso é tudo que precisamos para implementar uma biblioteca de promessas/futuros em Haskell!

Usando MVars dessa forma, nosso exemplo ficaria:

main = do
-- Criamos uma MVar vazia para a resposta
caixinhaParaAResposta <- newEmptyMVar
-- Começamos nosso thread
forkIO (fazRequestsHTTP caixinhaParaAResposta)

-- Essa linha vai ser impressa imediatamente
putStrLn "Eu apareço antes do callback ser executado!"
-- Esperamos até ter um valor na caixinha
valor <- takeMVar caixinhaParaAResposta
putStrLn ("valor: " ++ valor)

O código completo está aqui. E se rodar esse programa tenho o mesmo output:

[http] Fetching google.com
Eu apareço antes do callback ser executado!
[http] Fetching google.com/something
valor: <html>…</html>

A função fazRequestsHTTP quase não mudou; ao invés de chamar cb resultado agora chama putMVar caixinha resultado:

fazRequestsHTTP mvar = do
putStrLn "[http] Fetching google.com"
threadDelay 1000
putStrLn "[http] Fetching google.com/something"
threadDelay 1000
-- Põe o resultado na caixinha
putMVar mvar "<html>...</html>"

Promessas/Futuros de verdade Haskell

O fato de que podemos emular promessas com tanta facilidade é muito legal. Você pode escrever uma biblioteca de promessas em algumas linhas usando essas primitivas. Como esse não é um post introdutório a Haskell ou à bom design de bibliotecas na linguagem, basta mencionar a biblioteca async. A biblioteca usa primitivas parecidas com essa para nos dar o tipo Async algumTipo. Ele representa um futuro para algumTipo, e é basicamente implementado como um ThreadId, mais uma MVar (não é uma MVar, mas é algo análogo). No nosso exemplo, usaríamos a função async com tipo:

IO a -> IO (Async a)

O que ela faz é receber uma ação e retornar um futuro para seu resultado. A outra função básica é a wait com tipo:

Async a -> IO a

Essa função recebe um futuro/promessa, espera que termine e retorna seu resultado. Isso é uma implementação de async/await em Haskell usando primitivas muito simples (você pode implementar outras e melhores abstrações).

Usando esse paradigma, nosso programa fica mais natural:

main = do
-- Criamos nossa promessa
promessaParaResposta <- async fazRequestsHTTP

-- Essa linha vai ser impressa imediatamente
putStrLn "Eu apareço antes do callback ser executado!"

-- Esperamos pelo valor da promessa
valor <- wait promessaParaResposta

putStrLn ("valor: " ++ valor)

E o mais interessante é pensar na nossa função fazRequestsHTTP que agora é totalmente síncrona e nada sabe sobre concorrência:

fazRequestsHTTP = do
putStrLn "[http] Fetching google.com"
threadDelay 1000
putStrLn "[http] Fetching google.com/something"
threadDelay 1000

-- Retorna o resultado
return "<html>...</html>"

O programa completo está aqui.

Para o infinito e além

Mostrei aqui brevemente três primitivas do ecossistema de Haskell. As green-threads, que nos permitem executar ações em contextos diferentes facilmente, usando forkIO, as MVars, que nos permitem dividir estado e criar padrões de concorrência mais completos e os Asyncs que usam essas e poucas outras primitivas para dar promessas/futuros para o Haskell.

Tentei ser breve e só dar um gostinho do tipo de coisa que podemos fazer. Também há suporte para Channels e mais notavelmente memória transacional (transações estilo SQL com rollback no caso de conflito para a manipulação de memória), sugiro que dê uma olhada nos seguintes recursos:

HaskellBR

Venha participar da formação da comunidade HaskellBR, com o objetivo de melhorar a comunidade brasileira ao redor da linguagem de programação Haskell.

Entre no blog, no site, no Slack, no IRC, na lista de e-mails, participe do nosso próximo encontro em São Paulo ou dê uma olhada no código no GitHub e na nossa instância privada do Gogs.

No momento, está correndo a tradução de uma série de artigos: "24 dias de Hackage, 2015". Feita coletivamente no GitHub.

Uma introdução leve à linguagem está no nosso primeiro post.

@yamadapc