paint-brush
Quebrando Axiomas na Execução do Programapor@nekto0n
20,961 leituras
20,961 leituras

Quebrando Axiomas na Execução do Programa

por Nikita Vetoshkin9m2023/10/24
Read on Terminal Reader

Muito longo; Para ler

O autor, um engenheiro de software experiente, compartilha insights sobre sua jornada do código sequencial aos sistemas distribuídos. Eles enfatizam que adotar a execução não serializada, multithreading e computação distribuída pode levar a um melhor desempenho e resiliência. Embora introduza complexidade, é uma jornada de descoberta e recursos aprimorados no desenvolvimento de software.
featured image - Quebrando Axiomas na Execução do Programa
Nikita Vetoshkin HackerNoon profile picture


Cometendo novos erros

Sou engenheiro de software há cerca de 15 anos. Ao longo da minha carreira, aprendi muito e apliquei esses aprendizados para projetar e implementar (e ocasionalmente eliminar ou deixar como estão) muitos sistemas distribuídos. Ao longo do caminho cometi vários erros e continuo cometendo-os. Mas como meu foco principal era a confiabilidade, estive analisando minha experiência e a comunidade para encontrar maneiras de minimizar a frequência de erros. Meu lema é: devemos absolutamente tentar cometer novos erros (menos óbvios, mais sofisticados). Errar é bom – é assim que aprendemos, repetindo – é triste e desanimador.


Provavelmente é isso que sempre me fascinou na matemática. Não só porque é elegante e conciso, mas porque o seu rigor lógico evita erros. Isso força você a pensar sobre seu contexto atual, em quais postulados e teoremas você pode confiar. Seguir essas regras é frutífero, você obtém o resultado correto. É verdade que a ciência da computação é um ramo da matemática. Mas o que normalmente praticamos é engenharia de software, uma coisa muito distinta. Aplicamos as conquistas e descobertas da ciência da computação à prática, levando em consideração as restrições de tempo e as necessidades de negócios. Este blog é uma tentativa de aplicar o raciocínio semimatemático ao design e implementação de programas de computador. Apresentaremos um modelo de diferentes regimes de execução que fornece uma estrutura para evitar muitos erros de programação.


De origens humildes

Quando aprendemos a programar e damos nossos primeiros passos experimentais (ou ousados), geralmente começamos com algo simples:


  • programando loops, fazendo aritmética básica e imprimindo os resultados em um terminal
  • resolvendo problemas matemáticos, provavelmente em algum ambiente especializado como MathCAD ou Mathematica


Adquirimos memória muscular, aprendemos a sintaxe da linguagem e, o mais importante, mudamos a maneira como pensamos e raciocinamos. Aprendemos a ler o código, a fazer suposições sobre como ele está sendo executado. Quase nunca começamos lendo um padrão de linguagem e lemos atentamente sua seção “Modelo de memória” - porque ainda não estamos equipados para apreciá-lo e utilizá-lo totalmente. Praticamos tentativa e erro: introduzimos bugs lógicos e aritméticos em nossos primeiros programas. Esses erros nos ensinam a verificar nossas suposições: esse loop invariante está correto, podemos comparar o índice e o comprimento do elemento do array dessa maneira (onde você coloca esse -1)? Mas se não vemos algum tipo de erro, muitas vezes internalizamos implicitamente alguns invariantes o sistema nos impõe e nos fornece.


Ou seja, este:


As linhas de código são sempre avaliadas na mesma ordem (serializadas).

Este postulado nos permite assumir que as próximas proposições são verdadeiras (não vamos prová-las):


  • a ordem de avaliação não muda entre as execuções
  • chamadas de função sempre retornam


Os axiomas matemáticos permitem derivar e construir estruturas maiores sobre uma base sólida. Em matemática, temos a geometria euclidiana com postulados 4+1. O último diz:

linhas paralelas permanecem paralelas, não se cruzam nem divergem


Durante milénios, os matemáticos tentaram prová-lo e derivá-lo dos quatro primeiros. Acontece que isso não é possível. Podemos substituir este postulado das “retas paralelas” por alternativas e obter diferentes tipos de geometrias (nomeadamente, hiperbólicas e elípticas), que abrem novas perspectivas e se revelam aplicáveis e úteis. Afinal, a superfície do nosso planeta não é plana e temos que levar isso em conta, por exemplo, no software GPS e nas rotas dos aviões.

A necessidade de mudança

Mas antes disso vamos parar e fazer as perguntas mais de engenharia: por que se preocupar? Se o programa faz o seu trabalho, é fácil de apoiar, manter e evoluir, por que deveríamos desistir dessa invariante confortável de execução sequencial previsível em primeiro lugar?


Vejo duas respostas. O primeiro é o desempenho . Se conseguirmos fazer nosso programa rodar duas vezes mais rápido ou de forma semelhante - exigir metade do hardware - isso será uma conquista de engenharia. Se usarmos a mesma quantidade de recursos computacionais, podemos processar 2x (ou 3, 4, 5, 10x) de dados - isso pode abrir aplicativos completamente novos do mesmo programa. Ele pode ser executado em um telefone celular no seu bolso, em vez de em um servidor. Às vezes, podemos acelerar aplicando algoritmos inteligentes ou reescrevendo em uma linguagem de melhor desempenho. Estas são as nossas primeiras opções a explorar, sim. Mas eles têm um limite. A arquitetura quase sempre supera a implementação. A lei de Moor não tem funcionado muito bem ultimamente, o desempenho de uma única CPU está crescendo lentamente, o desempenho da RAM (latência, principalmente) está ficando para trás. Então, naturalmente, os engenheiros começaram a procurar outras opções.


A segunda consideração é a confiabilidade . A natureza é caótica, a segunda lei da termodinâmica trabalha constantemente contra qualquer coisa precisa, sequencial e repetível. Bits giram, materiais se degradam, falta energia, fios são cortados impedindo a execução de nossos programas. Manter a abstração sequencial e repetível torna-se uma tarefa difícil. Se nossos programas sobrevivessem às falhas de software e hardware, poderíamos fornecer serviços que tivessem uma vantagem comercial competitiva – essa é outra tarefa de engenharia que podemos começar a abordar.


Equipados com o objetivo, podemos iniciar experimentos com abordagens não serializadas.


Threads de execução

Vejamos este pedaço de pseudocódigo:


```

def fetch_coordinates(poi: str) -> Point:

def find_pois(center: Point, distance: int) -> List[str]:

def get_my_location() -> Point:


def fetch_coordinates(p) - Point:

def main():

me = get_my_location()

for point in find_pois(me, 500):
loc = fetch_coordinates(point)
sys.stdout.write(f“Name: {point} is at x={loc.x} y={loc.y}”)

Podemos ler o código de cima a baixo e assumir razoavelmente que a função `find_pois` será chamada após `get_my_location`. E buscaremos e retornaremos as coordenadas do primeiro POI após buscar o próximo. Essas suposições estão corretas e permitem construir um modelo mental, uma razão sobre o programa.


Vamos imaginar que podemos fazer com que nosso código seja executado de forma não sequencial. Há muitas maneiras de fazer isso sintaticamente. Iremos pular experimentos com reordenação de instruções (que é o que os compiladores e CPUs modernos fazem) e estender nossa linguagem para que possamos expressar um novo regime de execução de função : simultaneamente ou mesmo em paralelo em relação a outras funções. Reformulando, precisamos introduzir vários threads de execução. As funções do nosso programa são executadas em um ambiente específico (criado e mantido pelo SO), no momento estamos interessados em memória virtual endereçável e em um thread - uma unidade de escalonamento, algo que pode ser executado por uma CPU.


Threads vêm em diferentes sabores: thread POSIX, thread verde, coroutine, goroutine. Os detalhes diferem muito, mas tudo se resume a algo que pode ser executado. Se várias funções puderem ser executadas simultaneamente, cada uma precisará de sua própria unidade de agendamento. É daí que vem o multi-threading, em vez de um, temos vários threads de execução. Alguns ambientes (MPI) e linguagens podem criar threads implicitamente, mas geralmente temos que fazer isso explicitamente usando `pthread_create` em C, classes de módulo `threading` em Python ou uma simples instrução `go` em Go. Com algumas precauções, podemos fazer com que o mesmo código seja executado principalmente em paralelo:


 def fetch_coordinates(poi, results, idx) -> None: … results[idx] = poi def main(): me = get_my_location() points = find_pois(me, 500) results = [None] * len(points) # Reserve space for each result threads = [] for i, point in enumerate(find_pois(me, 500)): # i - index for result thr = threading.Thread(target=fetch_coordinates, args=(poi, results, i)) thr.start() threads.append(thr) for thr in threads: thr.wait() for point, result in zip(points, results): sys.stdout.write(f“Name: {poi} is at x={loc.x} y={loc.y}”)


Atingimos nosso objetivo de desempenho: nosso programa pode ser executado em várias CPUs e dimensionado conforme o número de núcleos aumenta e termina mais rápido. Próxima questão de engenharia que devemos fazer: a que custo?

Desistimos intencionalmente da execução serializada e previsível. Há sem bijeção entre uma função + ponto no tempo e os dados. Em cada momento há sempre um único mapeamento entre uma função em execução e seus dados:


Várias funções agora funcionam com dados simultaneamente:


A próxima consequência é que uma função pode terminar antes da outra desta vez, da próxima vez pode ser de outra maneira. Este novo regime de execução leva a corridas de dados: quando funções simultâneas trabalham com dados, significa que a ordem das operações aplicadas aos dados é indefinida. Começamos a encontrar corridas de dados e aprendemos a lidar com elas usando:

  • seções críticas: mutexes (e spinlocks)
  • algoritmos sem bloqueio (a forma mais simples está no trecho acima)
  • ferramentas de detecção de corrida
  • etc.


Neste ponto, descobrimos pelo menos duas coisas. Primeiro, existem várias maneiras de acessar dados. Alguns dados são local (por exemplo, variáveis com escopo de função) e somente nós podemos ver (e acessá-lo) e, portanto, está sempre no estado em que o deixamos. No entanto, alguns dados são partilhados ou controlo remoto . Ele ainda reside em nossa memória de processo, mas usamos formas especiais de acessá-lo e ele pode ficar fora de sincronia. Em alguns casos, para trabalhar com ele, nós o copiamos para nossa memória local para evitar corridas de dados - é por isso == .clone() ==é popular em Rust.


Quando continuamos com essa linha de raciocínio, outras técnicas, como armazenamento local de thread, surgem naturalmente. Acabamos de adquirir um novo gadget em nosso conjunto de ferramentas de programação, expandindo o que podemos alcançar com a construção de software.


No entanto, há uma invariante na qual ainda podemos confiar. Quando buscamos dados compartilhados (remotos) de um thread, sempre os conseguimos. Não há situação em que algum pedaço de memória não esteja disponível. O sistema operacional encerrará todos os participantes (threads) eliminando o processo se a região da memória física de apoio não funcionar corretamente. O mesmo se aplica ao “nosso” thread, se bloquearmos um mutex, não há como perder o bloqueio e devemos parar o que estamos fazendo imediatamente. Podemos confiar nesta invariante (imposta pelo sistema operacional e pelo hardware moderno) de que todos os participantes estão vivos ou mortos. Todos compartilham o destino : se o processo (OOM), sistema operacional (bug do kernel) ou hardware encontrar um problema - todos os nossos threads deixarão de existir juntos sem efeitos colaterais externos.


Inventando um processo

Uma coisa importante a ser observada. Como demos esse primeiro passo ao introduzir threads? Nós nos separamos, bifurcamos. Em vez de ter uma unidade de agendamento, introduzimos múltiplas. Vamos continuar aplicando essa abordagem de não compartilhamento e ver no que dá. Desta vez copiamos a memória virtual do processo. Isso é chamado de gerar um processo . Podemos executar outra instância do nosso programa ou iniciar outro utilitário existente. Esta é uma ótima abordagem para:

  • reutilizar outro código com limites estritos
  • executar código não confiável, isolando-o de nossa própria memória


Quase todos == navegadores modernos ==trabalhe dessa maneira, para que eles possam executar código executável Javascript não confiável baixado da Internet e encerrá-lo de forma confiável quando você fechar uma guia, sem desativar todo o aplicativo.

Este é mais um regime de execução que descobrimos ao abrir mão do destino invariante compartilhado e cancelar o compartilhamento da memória virtual e fazer uma cópia. As cópias não são gratuitas:

  • O sistema operacional precisa gerenciar estruturas de dados relacionadas à memória (para manter o mapeamento virtual -> físico)
  • Alguns bits podem ter sido compartilhados e, portanto, os processos consomem memória adicional



Libertando-se

Por que parar aqui? Vamos explorar o que mais podemos copiar e distribuir nosso programa. Mas por que distribuir em primeiro lugar? Em muitos casos, as tarefas disponíveis podem ser resolvidas usando uma única máquina.


Precisamos ir distribuídos para escapar do destino compartilhado princípios para que nosso software pare de depender dos problemas inevitáveis que as camadas subjacentes encontram.


Para nomear alguns:

  • Atualizações de sistema operacional: de vez em quando precisamos reiniciar nossas máquinas

  • Falhas de hardware: acontecem com mais frequência do que gostaríamos

  • Falhas externas: quedas de energia e de rede são importantes.


Se copiarmos um sistema operacional, chamaremos isso de máquina virtual e podemos executar os programas dos clientes em uma máquina física e construir um enorme negócio na nuvem. Se pegarmos dois ou mais computadores e executarmos nossos programas em cada um deles, nosso programa poderá sobreviver até mesmo a uma falha de hardware, fornecendo serviço 24 horas por dia, 7 dias por semana e ganhando uma vantagem competitiva. Há muito tempo, as grandes corporações foram ainda mais longe e agora os gigantes da Internet executam cópias em diferentes centros de dados e até mesmo em continentes, tornando assim um programa resistente a um tufão ou a uma simples queda de energia.


Mas esta independência tem um preço: as antigas invariantes não são aplicadas, estamos por nossa conta. Não se preocupe, não somos os primeiros. Existem muitas técnicas, ferramentas e serviços para nos ajudar.


Aprendizado

Acabamos de adquirir a capacidade de raciocinar sobre sistemas e seus respectivos regimes de execução. Dentro de cada sistema de grande escala, a maioria das partes são familiares sequenciais e sem estado, muitos componentes são multithread com tipos de memória e hierarquias, todos mantidos juntos por uma mistura de algumas partes verdadeiramente distribuídas:


O objetivo é ser capaz de distinguir onde estamos atualmente, quais invariantes mantêm e agir (modificar/projetar) de acordo. Destacamos o raciocínio básico, transformando “incógnitas desconhecidas” em “incógnitas conhecidas”. Não leve isso a sério, este é um progresso significativo.