Desenvolvimento

16 mai, 2012

O jogo de programação de sistemas distribuídos – qual é o seu nível?

Publicidade

Introdução

Quando a programação de sistemas distribuídos se torna parte da sua vida, você passa por uma curva de aprendizagem. Este artigo tenta descrever o meu nível atual de compreensão do campo, e espero que ressalte erros suficientes para que você possa seguir o melhor caminho rumo ao esclarecimento: aprendendo com os erros dos outros.

Para sua informação: eu entrei no Nível 1, em 1995, e atualmente estou  no Nível 3. Onde você se vê?

Nível 0: Dummies

Todo programador começa por aqui. Não vou comentar muito aqui, já que não há muito que dizer. Em vez disso, cito algumas conversas que tive, e ofereço alguns conselhos para os desenvolvedores que nunca lutaram em sistemas distribuídos.

NN1: “replicação em sistemas distribuídos é fácil, basta deixar todas as máquinas armazenarem o item ao mesmo tempo”

Outra conversa (do fundo da minha memória):

NN: “Para o nosso atirador em primeira pessoa, vamos escrever o nosso próprio mecanismo de networking”
ME: “Por quê?”
NN: “Existem mecanismos comerciais bons, porém os custos de licença são caros e não queremos pagar por isso.”
ME: “Você tem alguma experiência em sistemas distribuídos?”
NN: “Sim, eu escrevi um socket server antes.”
ME: “Quanto tempo você acha que vai demorar para escrevê-lo?”
NN: “Eu acho que 2 semanas. Apenas para ser realmente seguro, nós planejamos 4.”

Às vezes, é melhor ficar em silêncio.

Nível 1: RPC

RMI é uma técnica muito poderosa para a construção de grandes sistemas. O fato de que a técnica pode ser descrita, juntamente com um exemplo de trabalho, em apenas algumas páginas, fala de volumes de Java. RMI é tremendamente excitante e é simples de usar. Você pode chamar para qualquer servidor o qual você pode ligar, e você pode construir redes de objetos distribuídos. RMI abre a porta para sistemas de software que anteriormente eram muito complexos para se construir.

Peter van der Linden, Just Java (4ª edição, Sun Microsystems)

Deixe-me começar dizendo que não estou sacaneando esse livro. Lembro-me claramente de que foi divertido ler (especialmente as anedotas entre os capítulos), e usei isso para as aulas de Java que eu costumava dar (em um universo diferente, muito tempo atrás). No geral, eu gosto dele. Sua atitude em relação a RMI, no entanto, é típica do Nível 1 de design do aplicativo distribuído. As pessoas que residem aqui compartilham a visão de objetos unificados. Na verdade, Waldo e outros o descrevem em detalhes em seu papel histórico “a note on distributed computing” (1994), mas irei resumir aqui:

A estratégia defendida para escrever aplicações distribuídas é uma abordagem de três fases. A primeira é para escrever a aplicação sem se preocupar com onde os objetos estão localizados e como a sua comunicação é implementada. A segunda fase é para ajustar o desempenho de “concretizar” localizações dos objetos e métodos de comunicação. A fase final é testar com “balas de verdade” (redes particionadas, máquinas deixando de funcionar,…).

A ideia é que seja uma chamada local ou remota, ela não tem impacto sobre a correção de um programa.

O mesmo documento, em seguida, analisa isso mais profundamente e mostra os problemas com ele. Entretanto, já se sabe há quase 20 anos que esse conceito está errado. Enfim, se Java RMI conseguiu alguma coisa, foi isto: mesmo se você remover o protocolo de transporte, de nomenclatura e de ligação e de serialização da equação, ele ainda não funciona. Pessoas com idade suficiente para lembrar o inferno chamado CORBA também lembrarão que ele não deu certo, mas eles têm uma desculpa: eles ainda estavam lutando contra todos os tipos de problemas de nível inferior. Java RMI jogou tudo isso fora e fez  com que as questões remanescentes chamassem a atenção. Existem dois deles. O primeiro é um mero aborrecimento:


Não é transparência de rede

Vamos dar uma olhada em um exemplo simples de Java RMI (retirada do mesmo ‘Just Java “)

1 public interface WeatherIntf extends javva.rmi.Remote{
2 public String getWeather() throws java.rmi.RemoteException;
3 }

Um cliente que quer usar o serviço de meteorologia precisa fazer assim:

1 try{
2 Remote robj = Naming.lookup("//localhost/WeatherServer");
3 WeatherIntf weatherserver = (WeatherInf) robj;
4 String forecast = weatherserver.getWeather();
5 System.out.println("The weather will be " + forecast);
6 }catch(Exception e){
7 System.out.println(e.getMessage());
8 }

O código do cliente precisa levar em consideração RemoteExceptions.
Se quiser ver que tipos de falha remota você poderá encontrar, dê uma olhada nas mais de 20 subclasses. Ok, então o código será um pouco menos bonito. Podemos viver com isso.


Falha parcial

O verdadeiro problema com RMI é que a chamada pode falhar parcialmente. Ela pode falhar antes de a ação na outra camada ser chamada, ou a invocação pode ter sucesso, mas o valor de retorno pode não fazê-la mais tarde, por qualquer motivo. Esses modos de falha são na verdade a propriedade de definição de sistemas distribuídos ou o contrário:

Um sistema distribuído é aquele em que a falha de um computador que você nem sabia que existia pode tornar o seu próprio computador inutilizável – Leslie Lamport

Se o método for apenas a recuperação de uma previsão do tempo, você pode simplesmente tentar novamente, mas se você estava tentando incrementar um counter, repetir pode ter resultados variando de 0 a 2 atualizações. A solução deve vir das ações idempotentes, mas nem sempre é possível construí-las. Além disso, uma vez que você decidiu por uma mudança semântica de sua chamada de método, você basicamente admite que RMI é diferente de uma chamada local. Isso seria reconhecer que RMI é uma fraude.

Em qualquer caso, o paradigma é uma falha, já que a transparência de rede e abstração arquitetônica de distribuição simplesmente nunca se concretizam. Nota-se também que algumas metodologias de software são mais afetadas do que outras. Algumas variações do scrum tendem a prototipar. Protótipos concentram-se no caminho feliz, e o caminho feliz não é o problema. Basicamente, isso significa que você nunca vai escapar do Nível 1 (desculpe, isso foi um golpe baixo. Eu sei.).

Pessoas que escapam do Nível 1 entendem que precisam tratar o problema com o respeito que ele merece. Abandonam a ideia de transparência da rede e atacam estrategicamente o tratamento de falha parcial.

Nível 2: algoritmos distribuídos + mensagens assíncronas + suporte de linguagens

<sarcasm> “o que precisamos: outro pacote RPC” </sarcasm> – Steve Vinoski

Ok, você aprendeu as falácias da computação distribuída. Você decidiu fazer um sacrifício e modelar a mensagem passando explicitamente para obter um controle de falha.

Você dividiu sua aplicação em duas camadas, a inferior sendo responsável pela rede e transporte de mensagem, enquanto a camada superior lida com a chegada de mensagens, e o que precisa ser feito quando elas chegam.

A camada superior implementa uma máquina de estado distribuído, e se você perguntar aos designers o que ela faz, eles dirão algo do tipo: “É uma aplicação multi-paxos em cima do TCP”.

Desenvolvimento-sábio, a estratégia se resume a isto: programadores desenvolvem primeiro a aplicação centralmente usando threads para simular os diferentes processos. Cada thread executa uma parte da máquina de estado distribuído, e é basicamente responsável por executar um loop de processamento de mensagens. Depois que o aplicativo estiver localmente completo e correto, as threads são retiradas para se tornarem processos reais em computadores remotos. Nesta fase, na ausência de problemas de rede, a aplicação distribuída já estará funcionando corretamente. Em uma segunda fase, a falha de tolerância pode ser alcançada sem rodeios, configurando cada uma das entidades distribuídas para reagir corretamente às falhas (eu citei de “A Fault Tolerant Abstraction for Transparent Distributed Programming”).

Falha parcial é tratada pelo design, por causa da máquina de estado distribuído. Com relação às threads, há um monte de opções, mas se você prefere coroutines (eles são chamados de fibras, threads leves, microthreads protothreads ou apenas theads em várias linguagens de programação, causando uma confusão babilônica), pois possibilitam o controle de concorrência de granulação fina.

Combinado com a visão de que “o C não vai tornar a minha rede mais rápida” você se move para linguagens de programação que suportam esse tipo de simultaneidade granular. As escolhas populares são (em ordem arbitrária):

(Observe como eles tendem a ser funcionais por natureza)

Como exemplo, vamos ver como esse código fica em Erlang (tirado de programação concorrrente em Erlang).

01 -module(tut15).
02
03 -export([start/0, ping/2, pong/0]).
04
05 ping(0, Pong_PID) ->
06 Pong_PID ! finished,
07 io:format("ping finished~n", []);
08
09 ping(N, Pong_PID) ->
10 Pong_PID ! {ping, self()},
11 receive
12 pong ->
13 io:format("Ping received pong~n", [])
14 end,
15 ping(N - 1, Pong_PID).
16
17 pong() ->
18 receive
19 finished ->
20 io:format("Pong finished~n", []);
21 {ping, Ping_PID} ->
22 io:format("Pong received ping~n", []),
23 Ping_PID ! pong,
24 pong()
25 end.
26
27 start() ->
28 Pong_PID = spawn(tut15, pong, []),
29 spawn(tut15, ping, [3, Pong_PID]).

Isso definitivamente é uma grande melhoria em relação a simples RPC. Você pode começar a argumentar sobre o que aconteceria se uma mensagem não chegasse.

Erlang ganha pontos de bônus por ter mensagens de Timeoutde e um builtin após a construção do Timeout, que permite que você modele e reaja a limites de tempo de uma forma elegante.

Então, você escolheu a sua estratégia, seu algoritmo distribuído, sua linguagem de programação e começa o trabalho. Você está confiante de que vai matar esse monstro de uma vez por todas, que você não é mais um fracote no Nível 1.

Infelizmente, em algum ponto da estrada, um tempo após seus primeiros lançamentos, você passa por turbulências. As pessoas dizem que seu aplicativo distribuído tem problemas. Os relatórios são todos variações sobre um tema. Eles começam com um indicador de frequência como “às vezes” ou “uma vez” e, em seguida, descrevem uma situação na qual o sistema está preso em um estado indesejável. Se tiver sorte, você tem registro adequado no lugar e começa a inspecionar os logs. Um pouco mais tarde, você descobre uma sequência infeliz de eventos que gerou a situação relatada. Na verdade, era um caso novo. Você nunca levou isso em consideração, e isso nunca apareceu durante os testes extensivos e de simulação que você fez. Então você altera o código para levar esse caso em consideração também.

Uma vez que você tenta pensar no futuro, você decide construir um macaco que, de maneira falsa, permite  que o seu sistema distribuído faça coisas tolas aleatoriamente. O macaco sacode sua gaiola, e rapidamente você descobre uma infinidade de cenários que levam a situações indesejáveis, como ficar preso (nunca chegar a um consenso) ou ainda pior: chegar a um estado inconsistente que nunca deveria ocorrer.

Ter um macaco foi uma ótima ideia, e certamente reduz a chance de encontrar algo que você nunca viu antes no campo. Uma vez que você acredita que uma correção de bug anda de mãos dadas com um caso de teste que produziu o primeiro bug, e agora prova seu fim, você se propôs a construir apenas esse teste. Seu problema, porém, é que a reprodução do cenário de falha é difícil, senão impossível. Você ouve os deuses enquanto ele sugerem que, em caso de dúvida, use a força bruta. Então você produz alguns testes e executa-os um zilhão de vezes para compensar a menor probabilidade de falha. Isso faz com que o processo de fixação de bug seja lento, e os conjuntos de teste, volumosos. Você compensa novamente fazendo “dividir e conquistar” no seu volume de testsets. Enfim, depois de um pesado investimento de esforço e de tempo, você de alguma forma consegue um sistema e um processo bastante estáveis.

Você chegou ao Nível 2. Sem novas descobertas, você ficará preso aqui para sempre.

Nível 3: Algoritmos distribuídos + mensagens assíncronas + pureza

Leva um tempo para perceber que uma combinação de longos macacos em execução para descobrir cenários do mal e força bruta para reproduzi-los não está funcionando. Usar força bruta demonstra apenas ignorância. Uma das ideias-chave de que você precisa é que se você só podia remover o indeterminismo da equação, você teria reprodutibilidade perfeita de todos os cenários. Um efeito colateral importante do Nível 2 da programação distribuída é que seu modelo de concorrência tende a se tornar viral em sua base de código. Você desejou controle de concorrência granular… bem, você entendeu. Está em toda parte. Então, concorrência causa indeterminismo, e indeterminismo causa problemas. Então, a concorrência deve ir. Você não pode abandoná-la: você precisa dela. Você apenas tem que bani-la da mistura com a sua máquina de estado distribuído. Em outras palavras, sua máquina de estado distribuído tem que se tornar uma função pura. Sem IO, sem simultaneidade, sem nada. Sua assinatura na máquina de estado será algo assim:

1 module type SM = sig
2 type state
3 type action
4 type msg
5 val step: msg -> state -> action * state
6 end

Você passa uma mensagem e um estado e você obtém uma ação e um estado resultante. Uma ação é basicamente tudo o que tenta mudar o mundo exterior, precisa de tempo para fazer isso, e pode falhar ao tentar. Ações típicas são:

  • enviar uma mensagem
  • programar um timeout
  • armazenar algo em um armazenamento persistente

A coisa importante para perceber aqui é que você só pode chegar a um novo estado por meio de uma nova mensagem. Nada mais. Os benefícios de tal rigoroso regime são vários. Controle perfeito, capacidade de reprodução perfeita e tracibility perfeita. Os custos também estão lá. Você é forçado a materializar todas as suas ações, que basicamente constitui um nível extra de manobras indiretas para reduzir a sua complexidade. Você também tem que modelar cada mudança do mundo exterior que precisa de sua atenção em uma mensagem.

Outra modificação do Nível 2 é a mudança no fluxo de controle. No Nível 2, um cliente vai tentar forçar uma atualização e pôr o mecanismo em movimento. Aqui, a máquina de estado distribuído assume controle total, e só vai considerar o pedido de um cliente quando ele estiver pronto e capaz de fazer algo de útil com ele. Então isso deve ser definido.

Se você explicar isso para um arquiteto (a) de Nível 2, , ele (a) aceitará isso mais ou menos como uma alternativa. Isso, no entanto, requer uma quantidade suficiente de dor (vamos chamar de experiência ou XP) para perceber que é a única alternativa viável.

Nível 4: Dominação real de sistemas distribuídos: felicidade, paz de espírito e uma boa noite de descanso

Para ser honesto, como eu sou um mero Nível 3 , eu não sei o que está acontecendo aqui. Estou convencido de que tanto a programação funcional e a transmissão de mensagens assíncronas são peças do quebra-cabeça, mas não são suficientes.

Permitam-me reiterar contra o que estou lutando. Primeiro, eu quero que minha implementação do algoritmo distribuído abranja integralmente todos os casos possíveis.

Isso é uma grande coisa para mim, já que perdi muito sono sendo chamado em questões de sistemas implantados (a maioria desses acaba sendo PEBKAC, mas alguns eram verdadeiros, e causaram frustração). Seria bom saber que a sua implementação é robusta. Devo tentar provadores de teoremas, devo fazer testes exaustivos? Eu não sei.

Como um aparte, para acrescentar apenas uma biblioteca btreeish chamada baardskeerder, sabemos que abrangemos todos os casos pela geração exaustiva de permutações inserir/excluir e afirmamos sua correção. Veja, não é assim tão simples, e eu estou um pouco hesitante em fazer Coqify da base de código.

Em segundo lugar, por razões de clareza e simplicidade, decidi não tocar em outros requisitos ortogonais, como serviço de descoberta, autenticação, autorização, privacidade e desempenho.

Com relação ao desempenho, podemos ter sorte tão quanto a mensagem assíncrona passe, pelo menos não parece contradizer as considerações de desempenho.

Segurança, porém, é que a um verdadeiro problema, porque ela corta quase tudo que você faz. Algumas pessoas pensam que a segurança é um molho que você pode derramar sobre o seu aplicativo para torná-lo seguro.

Infelizmente, nunca obtive sucesso com isso e, atualmente, acho que isso também precisa ser tratado estrategicamente durante os primeiros estágios do projeto.

Palavras de encerramento

Desenvolver sistemas distribuídos robustos é um problema difícil, que está praticamente sem solução, ou pelo menos não está resolvido para minha satisfação.

Tenho certeza de que a sua importância vai aumentar significativamente, à medida que a latência entre os processadores e tudo aumenta também. Isso resulta em uma área cada vez maior de aplicação para esse tipo de desenvolvimento de aplicativos.

À medida que o Nível 4 prossegue, talvez eu devesse perguntar ao Peter Van Roy. Ao longo dos anos, eu li um monte de seus trabalhos, e eles me ofereceram um monte de insights sobre meus próprios erros. A desvantagem do insight é que você vê os outros repetindo seus erros e, na maioria das vezes, não consigo convencer as pessoas de que elas devem fazer isso de forma diferente.

Provavelmente, isso acontece porque eu não posso oferecer a panaceia que eles querem. Eles querem RPC e que funcione. É perverso… quase religioso.

?

Texto original disponível em http://blog.incubaid.com/2012/03/28/the-game-of-distributed-systems-programming-which-level-are-you/