Programação funcional: idéias corretas sobre concorrência e estado?

21

Os defensores do FP alegaram que a concorrência é fácil porque seu paradigma evita um estado mutável. Eu não entendo.

Imagine que estamos criando um rastreamento de masmorra multijogador (semelhante a um roguel) usando FP, onde enfatizamos funções puras e estruturas de dados imutáveis. Geramos uma masmorra composta por salas, corredores, heróis, monstros e saques. Nosso mundo é efetivamente um gráfico objeto de estruturas e seus relacionamentos. À medida que as coisas mudam, nossa representação do mundo é alterada para refletir essas mudanças. Nosso herói mata um rato, pega uma espada curta, etc.

Para mim, o mundo (realidade atual) carrega essa ideia de estado e sinto falta de como a FP supera isso. À medida que nosso herói age, as funções alteram o estado do mundo. Parece que toda decisão (AI ou humana) precisa se basear no estado do mundo como está no presente. Onde permitiríamos a simultaneidade? Não podemos ter vários processos simultâneos para corrigir o estado do mundo, para que um processo não baseie seus resultados em algum estado expirado. Parece-me que todo controle deve ocorrer dentro de um único loop de controle, de modo que estamos sempre processando o estado presente representado pelo nosso gráfico de objetos atual do mundo.

Claramente, existem situações perfeitamente adequadas para simultaneidade (ou seja, ao processar tarefas isoladas cujos estados são independentes um do outro).

Não vejo como a concorrência é útil no meu exemplo e esse pode ser o problema. Eu posso estar deturpando a reivindicação de alguma forma.

Alguém pode representar melhor essa reivindicação?

Mario T. Lanza
fonte
1
Você está se referindo ao estado compartilhado; estado compartilhado sempre será o que é e sempre exigirá alguma forma de sincronização, a forma frequentemente preferida entre pessoas de FP pura é o STM, que permite tratar a memória compartilhada como memória local, tendo uma camada de abstração sobre ela que torna o acesso transacional, de modo que condições são tratadas automaticamente. Outra técnica para a memória compartilhada é passagem de mensagens, onde em vez de ter memória compartilhada, você tem memória local e conhecimento de outros atores para pedir sua memória local
Jimmy Hoffa
1
Então ... você está perguntando como a simultaneidade de estado compartilhado é fácil ajuda a gerenciar o estado em um aplicativo de thread único? Por outro lado, seu exemplo claramente se presta à simultaneidade conceitualmente (um encadeamento para cada entidade controlada pela IA), independentemente de ser implementado dessa maneira. Estou confuso o que você está perguntando aqui.
CA McCann
1
em uma palavra, Zippers
jk.
2
Todo objeto teria sua própria visão do mundo. Haverá consistência eventual . Provavelmente também é como as coisas funcionam em nosso "mundo real" com o colapso da função de onda .
herzmeister
1
Você pode achar "retrogames puramente funcionais" interessantes: prog21.dadgum.com/23.html
user802500

Respostas:

15

Vou tentar sugerir a resposta. Esta não é uma resposta, apenas uma ilustração introdutória. A resposta de @jk aponta para a coisa real, zíperes.

Imagine que você tem uma estrutura de árvore imutável. Você deseja alterar um nó inserindo um filho. Como resultado, você obtém uma árvore totalmente nova.

Mas a maior parte da nova árvore é exatamente igual à antiga. Uma implementação inteligente reutilizaria a maioria dos fragmentos de árvore, direcionando ponteiros ao redor do nó alterado:

Da Wikipedia

O livro de Okasaki está cheio de exemplos como este.

Então, suponho que você possa alterar razoavelmente pequenas partes do seu mundo de jogo a cada movimento (pegar uma moeda) e alterar apenas pequenas partes da estrutura de dados do seu mundo (a célula onde a moeda foi retirada). As peças que pertencem apenas a estados anteriores serão coletadas com o tempo.

Isso provavelmente leva alguma consideração ao projetar a estrutura do mundo dos jogos de dados de maneira apropriada. Infelizmente, não sou especialista nesses assuntos. Definitivamente, deve ser algo além de uma matriz NxM que alguém usaria como estrutura de dados mutáveis. Provavelmente deveria consistir em pedaços menores (corredores - células individuais) que apontam um para o outro, como os nós das árvores.

9000
fonte
3
+1: Para apontar para o livro de Okasaki. Eu não li, mas está na minha lista de tarefas. Eu acho que o que você descreveu é a solução correta. Como alternativa, você pode considerar os tipos de exclusividade (Limpo, en.wikipedia.org/wiki/Uniqueness_type ): usando esse tipo de tipo, você pode atualizar destrutivamente os objetos de dados, mantendo a transparência referencial.
Giorgio
Existe um benefício para os relacionamentos serem definidos por referência indireta por meio de chaves ou IDs? Ou seja, eu pensava que menos toques reais de uma estrutura para outra exigiriam menos alterações na estrutura mundial quando uma mudança ocorresse. Ou essa técnica não é realmente usada no FP?
Mario T. Lanza
9

A resposta do 9000 é metade da resposta, estruturas de dados persistentes permitem reutilizar partes inalteradas.

Você já deve estar pensando "ei, e se eu quiser mudar a raiz da árvore?" como está no exemplo, dado que agora significa alterar todos os nós. É aqui que os zíperes vêm em socorro. Eles permitem que o elemento em um foco seja alterado em O (1), e o foco pode ser movido para qualquer lugar da estrutura.

O outro ponto dos zíperes é que existe um zíper para praticamente qualquer tipo de dados que você deseja

jk.
fonte
Receio que levará algum tempo para cavar "zíperes", já que estou à margem de qualquer exploração de FP. Não tenho experiência com Haskell.
Mario T. Lanza
Vou tentar adicionar um exemplo hoje mais tarde
jk.
4

Programas de estilo funcional criam muitas oportunidades como essa para usar simultaneidade. Sempre que você transforma, filtra ou agrega uma coleção, e tudo é puro ou imutável, há uma oportunidade para a operação ser acelerada pela simultaneidade.

Por exemplo, suponha que você execute decisões de IA independentemente uma da outra e em nenhuma ordem específica. Eles não se revezam, todos tomam uma decisão simultaneamente e o mundo avança. O código pode ficar assim:

func MakeMonsterDecision curWorldState monster =
    ...
    ...
    return monsterDecision

func NextWorldState curWorldState =
    ...
    let monsterMakeDecisionForCurrentState = MakeMonsterDecision curWorldState
    let monsterDecisions = List.map monsterMakeDecisionForCurrentState activeMonsters
    ...
    return newWorldState

Você tem uma função para calcular o que um monstro fará com um estado mundial e aplicá-lo a todos os monstros como parte da computação do próximo estado mundial. Isso é algo natural a se fazer em uma linguagem funcional, e o compilador é livre para executar a etapa 'aplicar a todos os monstros' em paralelo.

Em uma linguagem imperativa, é mais provável que você repita todos os monstros, aplicando seus efeitos ao mundo. É mais fácil fazê-lo dessa maneira, porque você não deseja lidar com clonagem ou aliasing complicado. O compilador não pode executar os cálculos de monstro em paralelo nesse caso, porque as decisões iniciais dos monstros afetam as decisões posteriores dos monstros.

Craig Gidney
fonte
Isso ajuda bastante. Eu posso ver como em um jogo haveria um grande benefício em ter monstros decidindo simultaneamente o que eles farão a seguir.
Mario T. Lanza
4

Ouvir algumas palestras de Rich Hickey - essa em particular - aliviou minha confusão. Em um deles, ele indicou que não há problema em que processos concorrentes possam não ter o estado mais atual. Eu precisava ouvir aquilo. O que eu estava tendo problemas para digerir era que os programas realmente aceitariam basear as decisões em instantâneos do mundo que foram substituídos por outros mais recentes. Fiquei me perguntando como a FP simultânea contornou a questão de basear as decisões no estado antigo.

Em um aplicativo bancário, nunca desejaríamos basear uma decisão em um instantâneo de estado que foi substituído por um mais novo (ocorreu uma retirada).

Essa simultaneidade é fácil porque o paradigma do FP evita o estado mutável é uma afirmação técnica que não tenta dizer nada sobre os méritos lógicos de basear as decisões em estados potencialmente antigos. O FP ainda modela a mudança de estado. Não há como contornar isso.

Mario T. Lanza
fonte
0

Os defensores do FP alegaram que a concorrência é fácil porque seu paradigma evita um estado mutável. Eu não entendo.

Eu queria abordar essa questão geral como alguém que é um neófito funcional, mas que tem tido muitos efeitos colaterais ao longo dos anos e gostaria de mitigá-los, por todos os tipos de razões, incluindo razões mais fáceis (ou especificamente "mais seguras, menos propenso a erros ") simultaneidade. Quando olho para meus colegas funcionais e o que eles estão fazendo, a grama parece um pouco mais verde e cheira melhor, pelo menos nesse aspecto.

Algoritmos seriais

Dito isto, sobre o seu exemplo específico, se o seu problema é de natureza serial e B não pode ser executado até que A seja concluído, conceitualmente você não pode executar A e B em paralelo, não importa o quê. Você precisa encontrar uma maneira de quebrar a dependência da ordem, como na sua resposta, com base em movimentos paralelos usando o antigo estado do jogo ou usar uma estrutura de dados que permita que partes dela sejam modificadas independentemente para eliminar a dependência da ordem, conforme proposto nas outras respostas. , ou algo desse tipo. Mas há definitivamente uma parcela de problemas de design conceitual como este, onde você não pode necessariamente multithread tudo tão facilmente porque as coisas são imutáveis. Algumas coisas serão de natureza serial até que você encontre uma maneira inteligente de quebrar a dependência do pedido, se isso for possível.

Simultaneidade mais fácil

Dito isto, há muitos casos em que não conseguimos paralelizar programas que envolvem efeitos colaterais em lugares que poderiam melhorar potencialmente significativamente o desempenho simplesmente por causa da possibilidade de que ele pode não ser thread-safe. Um dos casos em que a eliminação do estado mutável (ou mais especificamente, efeitos colaterais externos) ajuda muito, como eu vejo, é que ele transforma "pode ​​ou não ser seguro para threads " em "definitivamente seguro para threads" .

Para tornar essa afirmação um pouco mais concreta, considere que eu lhe dou uma tarefa para implementar uma função de classificação em C que aceita um comparador e a usa para classificar uma matriz de elementos. Ele deve ser bastante generalizado, mas vou dar uma suposição fácil de que ele será usado contra entradas de uma escala (milhões de elementos ou mais) que, sem dúvida, será benéfico usar sempre uma implementação multithread. Você pode multithread sua função de classificação?

O problema é que você não pode, porque os comparadores que sua função de classificação chama podemcausar efeitos colaterais, a menos que você saiba como eles são implementados (ou, pelo menos, documentados) para todos os casos possíveis, o que é praticamente impossível sem a degeneralização da função. Um comparador pode fazer algo nojento como modificar uma variável global dentro de uma maneira não atômica. 99,9999% dos comparadores podem não fazer isso, mas ainda não podemos multithread essa função generalizada simplesmente devido aos 0,00001% dos casos que podem causar efeitos colaterais. Como resultado, talvez você precise oferecer uma função de classificação single-threaded e multithread e passar a responsabilidade aos programadores que a utilizam para decidir qual usar com base na segurança do thread. E as pessoas ainda podem usar a versão de thread único e perder oportunidades de multithread porque também podem não ter certeza se o comparador é seguro para threads,

Há muita inteligência que pode estar envolvida na racionalização da segurança das coisas sem abrir bloqueios em todos os lugares, o que pode desaparecer se tivermos apenas garantias de que as funções não causarão efeitos colaterais no presente e no futuro. E há medo: medo prático, porque quem já teve que depurar uma condição de corrida muitas vezes provavelmente hesitaria em multithreading qualquer coisa que eles não possam ter 110% de certeza é segura para threads e permanecerá como tal. Mesmo para os mais paranóicos (dos quais eu provavelmente sou pelo menos limítrofe), a função pura fornece a sensação de alívio e confiança que podemos chamar em paralelo com segurança.

E esse é um dos principais casos em que considero tão benéfico se você puder obter uma garantia absoluta de que tais funções são seguras contra threads, o que você obtém com linguagens funcionais puras. A outra é que as linguagens funcionais geralmente promovem a criação de funções livres de efeitos colaterais em primeiro lugar. Por exemplo, eles podem fornecer estruturas de dados persistentes, onde é razoavelmente eficiente inserir uma estrutura de dados massiva e gerar uma nova, com apenas uma pequena parte dela alterada do original sem tocar no original. Aqueles que trabalham sem essas estruturas de dados podem querer modificá-los diretamente e perder alguma segurança de encadeamento ao longo do caminho.

Efeitos colaterais

Dito isto, eu discordo de uma parte com todo o respeito devido aos meus amigos funcionais (que eu acho super legais):

[...] porque seu paradigma evita um estado mutável.

Não é necessariamente imutabilidade que torna a concorrência tão prática quanto eu a vejo. São funções que evitam causar efeitos colaterais. Se uma função insere uma matriz para classificar, copia e depois modifica a cópia para classificar seu conteúdo e gera a cópia, ela ainda é tão segura quanto a thread que uma que trabalha com algum tipo de matriz imutável, mesmo se você estiver passando a mesma entrada matriz a partir de vários segmentos. Então, acho que ainda há um lugar para tipos mutáveis ​​na criação de código muito compatível com a concorrência, por assim dizer, embora haja muitos benefícios adicionais para tipos imutáveis, incluindo estruturas de dados persistentes que eu não uso tanto por suas propriedades imutáveis, mas para elimine a despesa de ter que copiar tudo em profundidade para criar funções livres de efeitos colaterais.

E muitas vezes há sobrecarga para tornar as funções livres de efeitos colaterais na forma de embaralhar e copiar alguns dados adicionais, talvez um nível extra de indireção e, possivelmente, algum GC em partes de uma estrutura de dados persistente, mas eu olho para um dos meus amigos que tem uma máquina de 32 núcleos e acho que a troca provavelmente vale a pena se pudermos fazer mais coisas com mais confiança em paralelo.


fonte
1
"Estado mutável" sempre significa estado no nível do aplicativo, não no nível do procedimento. Eliminar ponteiros e passar parâmetros como valores de cópia é uma das técnicas inseridas no FP. Mas qualquer função útil precisa alterar o estado em algum nível - o objetivo da programação funcional é garantir que o estado mutável que pertence ao chamador não entre no procedimento e as mutações não saiam do procedimento, exceto pelos valores de retorno! Mas existem poucos programas que podem fazer muito trabalho sem alterar o estado, e os erros sempre aparecem novamente na interface.
Steve Steve
1
Para ser honesto, a maioria das linguagens modernas permite o uso de um estilo funcional de programação (com um pouco de disciplina) e, é claro, existem linguagens dedicadas a padrões funcionais. Mas é um padrão menos computacionalmente eficiente e é popularmente exagerado como solução para todos os males da mesma forma que a orientação a objetos era nos anos 90. A maioria dos programas não é vinculada a cálculos intensivos em CPU que se beneficiariam da paralelização e, por outro lado, é frequentemente devido à dificuldade de raciocinar, projetar e implementar o programa de uma maneira passível de execução paralela.
Steve Steve
1
A maioria dos programas que lidam com o estado mutável o faz porque é necessário por um motivo ou outro. E a maioria dos programas não está incorreta porque eles usam o estado compartilhado ou o atualizam de forma anormal - geralmente é porque eles recebem lixo inesperado como entrada (que determina uma saída de lixo) ou porque operam incorretamente em suas entradas (incorretamente no sentido do objetivo) para ser alcançado). Os padrões funcionais pouco fazem para resolver isso.
Steve Steve
1
@Steve Eu posso pelo menos concordar parcialmente com você, já que estou do lado de apenas explorar maneiras de fazer as coisas de maneira mais segura a partir de linguagens como C ou C ++, e realmente não acho que precisamos ficar completos puro funcional para fazê-lo. Mas acho que alguns dos conceitos do FP são úteis pelo menos. Acabei escrevendo uma resposta sobre como considero o PDS útil aqui, e o maior benefício que encontro sobre o PDS não é realmente a segurança de threads, mas coisas como instanciamento, edição não destrutiva, segurança de exceção, desfazer simples, etc: