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.
Quando aprendemos a programar e damos nossos primeiros passos experimentais (ou ousados), geralmente começamos com algo simples:
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
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):
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.
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.
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 :
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á
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:
Neste ponto, descobrimos pelo menos duas coisas. Primeiro, existem várias maneiras de acessar dados. Alguns dados são
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.
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:
Quase todos ==
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:
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 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.
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.