[Akitando] #40 - Entendendo Back-End para Iniciantes em Programação (Parte 1) | Série "Começando aos 40"

2019 February 20, 17:00 h

Disclaimer: esta série de posts são transcripts diretos dos scripts usados em cada video do canal Akitando. O texto tem erros de português mas é porque estou apenas publicando exatamente como foi usado pra gravar o video, perdoem os errinhos.

Descrição no YouTube

Este é o 5o episódio da série "Começando aos 40". Você deve assistir os episódios anteriores da série pra entender onde estamos e recomendo assistir os 2 vídeos da série "Sua Linguagem Não É Especial". No episódio de hoje vou começar a introduzir os conceitos básicos para o que chamamos de "back-end", que na prática é a própria introdução à programação.

A intenção do vídeo não é serem aulas nem tutoriais, mas conectar os pontos de diversos assuntos que vocês vão encontrar de forma desconexa se sair pesquisando pelo Google. Se você fez faculdade, já deveriam saber tudo que vou dizer aqui, então veja o que faltou nos estudos e acelere!

Em resumo, vou falar rapidamente sobre os conceitos mais simples, linguagem Assembly, compiladores, interpretadores, máquinas virtuais, bytecode, processos, forks, threads, tudo que qualquer programador tem obrigação de saber na ponta da língua e em paralelo vou passar rapidamente de novo pelos anos 90.

Como o episódio ficou longo, eu dividi em 2 partes. Na de hoje vou só até o crash das ponto coms em 2001, e vamos continuar dali na Parte 2 da semana que vem.

Links:

Canais Retro:

Me sigam também em outras redes sociais:

Script

Olá pessoal, Fabio Akita

Espero que vocês tenham sobrevivido ao meu último episódio onde gastei um longo tempo tentando dar contexto sobre as tecnologias de Front-End. Também espero que tenham entendido a receita: na dúvida, encontrem o contexto de porque determinada tecnologia foi criada. Tecnologia são ferramentas, ferramentas foram criadas com um propósito. Por isso não existe uma única tecnologia que resolve todos os problemas. Já diria o sábio que pra quem acha que só existe martelo, todos os problemas são pregos.

E seguindo nessa mesma linha, no episódio de HOJE, vou tentar dar contexto para as tecnologias que chamamos de Back-end. Porém, back-end é muito, mas muito maior do que front-end e mesmo se eu fizer um episódio de uma hora, não vai dar pra explicar tudo que eu quero. Eu comecei a escrever o script deste episódio, aí cheguei na página 10, fui escrevendo e cheguei na página 20 e mesmo vocês tendo gostado do episódio anterior que deu quase 50 minutos se eu tentar encaixar tudo agora vai facilmente dar mais de 2 horas. E se eu fizer isso vou correr demais e talvez não explicar direito, então vou ser obrigado a cortar em vários episódios e mesmo assim vai ficar grande! Então vamos lá porque temos bastante coisa pra cobrir.

Pra começar, a pergunta mais comum quando se fala em programação back-end pra Web é qual linguagem aprender? Javascript? Java? C#? PHP? Python? Ruby? Elixir? Go? Scala? Clojure? Rust? Nos episódios sobre Sua Linguagem não é especial que eu espero que tenham assistido, eu dei uma rápida introdução nos primeiros 40 anos de história das linguagens e como uma linguagem veio influenciando as outras. Assistam essa história antes e, mesmo que não tenha intenção de se tornar um desenvolvedor front-end, assista o episódio anterior antes pra ter o contexto necessário. Se não assistiu vai lá, eu espero ….

Pronto, já foi? Então vamos lá!

(...)

No Brasil infelizmente nós praticamente pulamos a história da computação até os anos 80 e só começamos mais ou menos nos anos 90. Isso porque antes dos anos 80 vivíamos numa ditadura, e perdemos o timing da evolução dos transistores e nunca tivemos um mercado decente de microprocessadores. Nosso mercado era fechado a importar coisas de fora. Maldita reserva de mercado, toda reserva de mercado é um puta erro. A gente precisava ir até o Paraguai pra conseguir comprar máquina decente. Havia também computadores nacionais como os da Itautec e Microsiga que faziam clones de IBM PC e mesmo de outros como os famosos Sinclairs, quem não lembra da série TK como o TK-90? Tinha a Hotbit e Gradiente fazendo MSX que eu gostava bastante. E tinha a Unitron que fazia clones de Apple II e chegou até mesmo a fazer um clone excepcional do Mac 512, que era quase igual e rolou até uma controvérsia direto com a Apple onde eles quase pediram sanções dos EUA contra exportações do Brasil por causa disso.

Essa história é interessante e explica porque por muitos anos Steve Jobs odiou o Brasil, vou deixar linkado na descrição abaixo sobre isso. E tivemos até sistemas operacionais nacionais como o Sisne que era um clone de MS-DOS, só que em português. Eu brincava de Basic com computadores como Apple II e MSX quando ia na casa de amigos, meu primeiro mesmo veio do paraguai no fim dos anos 80. Foi um PC XT de talvez 10 Mhz ou algo assim, poderosos 1 MB de RAM e 10 MB de HD, com monitor CGA verde, de incríveis 4 tons de verde. Fiz muitos programas em dBase e Clipper nessa máquina, fiz muitos trabalhos de escola com ele. E joguei muito também. Você não precisa da máquina mais potente pra aprender a programar, você precisa aprender a controlar a máquina que tem e tirar o absoluto máximo que ela pode dar. Ninguém nunca vai dirigir bem uma Ferrari se sequer tem coordenação pra trocar marcha num Gol.

Vocês vão notar que mesmo hoje em dia, em pleno 2019, é muito raro encontrar programadores de cabelo branco. Eu mesmo só comecei a ganhar os meus nos últimos poucos anos. E esse é um dos motivos: havia muito pouco programador antes dos anos 80. Mesmo durante os anos 80 era difícil ser programador. Então os que estão na ativa são ainda mais raros. Nos Estados Unidos você vê vários, incluindo famosos como os Uncle Bob da vida. Por isso temos muito pouca gente realmente com décadas de experiência. Se programador no Brasil só se tornou uma carreira realmente mainstream depois do advento da Web, antes era bem mais nicho. E por isso você vê muito pouca gente que pode dizer coisas como: trabalhei em projetos com Lisp, ou Smalltalk, ou PROLOG, sem ser do meio acadêmico.

Bom, antes de falarmos sobre as linguagens novas, precisamos começar do começo. Todo hardware, mais especificamente a CPU vem de fábrica entendendo um certo conjunto de instruções ou funções. Eles tem registradores que é como se fosse um tipo de memória. Você coloca valores nesses registradores e chama uma instrução que é mais ou menos a mesma coisa que chamar uma função passando argumentos, se você começou a aprender alguma linguagem. Daí a CPU executa alguma coisa e grava o resultado em outros registradores e você pode ler a resposta. A linguagem que usamos pra falar diretamente com a máquina é Assembly, e seu montador, o assembler, traduz o código escrito em texto com mnemônicos como JMP ou ADD ou MOV diretamente nas instruções binárias da máquina.

Além do hardware temos instruções específicas do sistema operacional, o que chamamos de syscalls ou chamadas de sistema. Então o tal binário nativo que falamos são instruções pra máquina e pro sistema operacional que é uma abstração da máquina. Sempre que falamos em abstrações pense que é uma forma de pegar uma sequência de instruções que, por si só, podem ser difíceis de entender o que vai fazer e encapsular numa chamada só com um nome mais descritivo. É o que você chamaria de funções. Um sistema operacional tem várias funções que abstraem instruções mais simples que a máquina oferece, como ler arquivos, ou ou alocar espaço na memória pra um programa.

É extremamente chato e trabalhoso escrever Assembly diretamente, além de ser bem ineficiente hoje em dia, tanto porque precisamos escrever muito código pra fazer uma aplicação inteira e porque a performance não vai ser melhor e vou explicar porque a seguir. Mas pior, o código que escrevemos é totalmente dependente da CPU que estamos usando. Programar Assembly de Intel x86 ou PowerPC ou ARM exigem instruções diferentes. Por isso temos linguagens como C. Um “compiladores” traduz o código como escrito em C e realiza diversas otimizações antes de terminar de traduzir em instruções binárias. Mas mais importante, podemos usar o mesmo código C com diferentes compiladores pra gerar as instruções de um Intel x86 ou ARM sem mudar o código C.

Uma linguagem como C cria um programa binário nativo. Ou seja, ele depende de chamar diretamente instruções do sistema operacional e da máquina. Um compilador faz muito mais que só traduzir código um pra um pra instruções de máquina, ele vai tentar o possível pra melhorar seu código primeiro. Vai tirar instruções que não servem pra nada. Vai tentar executar coisas fora de ordem se o resultado for o mesmo. Vai trocar pedaços inteiros por instruções mais simples. Vai tentar prever o que é o próximo resultado e já pré-calcular e assim por diante. Ele literalmente faz mágica, portanto parta do princípio que seu código deve ser escrito primariamente pra que outro ser humano consiga entender e é trabalho do compilador melhorar pra ficar mais eficiente na máquina. Você eventualmente pode otimizar seu código pra ficar mais eficiente quando ficar complexo, mas se sua linguagem exige que você faça isso o tempo todo, sua linguagem é defeituosa.

No processo de transformar um código texto em um binário que executa na máquina você precisa primeiro de um compilador. Só que ninguém escreve um programa 100% do zero. Normalmente você nem fala diretamente com o hardware ou o sistema operacional. Existem dezenas de bibliotecas que você deve reusar e que vão facilitar seu trabalho. No C você vai precisar de coisas como LIBC ou no C++ você talvez vai usar coisas como Boost. São grandes bibliotecas reusáveis pra você não ter que ficar reinventando a roda toda hora. No Windows seriam as DLLs que você já deve ter visto, como win32. No Linux a extensão dessas bibliotecas binárias é ponto SO. Agora você precisa de um linker que vai ligar seu programa compilado com essas bibliotecas. E você tem duas opções, linkar estaticamente ou linkar dinamicamente.

Pra linkar estaticamente você precisa do binários antes dele virarem um SO ou DLL, ou seja do que chamamos de arquivo de objetos, normalmente com extensão ponto O que é transformado em ponto A ou ponto LIB em Windows. Se seu programa é um binário e a biblioteca é outro binário, ao linkar estaticamente você vai ter as duas coisas mescladas num único bináriozão. Porém se todo programa que você fizer tiver que mesclar o binário das bibliotecas, vai ter um binários gigantes. Por outro lado você pode linkar dinamicamente, pra isso você precisa do arquivo de cabeçalho da biblioteca que é um código que declara quais funções estão expostas no binário dessa biblioteca. Dessa forma todos os programas vão ter tamanho menor e vão reusar o mesmo binário da biblioteca que está instalado no seu sistema.

Porém ao copiar esse binário pra outra máquina você precisa garantir que a biblioteca dinâmica está lá também. É por isso que muitos programas no Windows antigamente mandavam você instalar antes o runtime do Visual Basic 6 ou hoje em dia o runtime do .NET ou outros componentes como DirectX pra games. E mais, você precisa instalar a versão certa de cada biblioteca ou vai ter problemas. Se você já está instalando e configurando seu Linux do zero na mão, já deve ter aprendido sobre variáveis de ambiente. Não basta só baixar as bibliotecas e copiar em algum diretório qualquer. Pra que seus executáveis encontrem as bibliotecas, se você instalar na mão, vai precisar mexer em variáveis de ambiente como LD_LIBRARY_PATH que indicam onde procurar por elas.

Essa discussão se linkar dinamicamente ou estaticamente é melhor é uma das coisas que programadores gostam de discutir. Ambas tem vantagens e desvantagens. Se você tem todo o código-fonte de tudo disponível o tempo todo e quer um binário que você possa copiar pra outra máquina e não se preocupar com dependências, pode compilar tudo estático, ficar com um binário gigante e quando eu digo gigante estou falando de binários de 200Mb ou mais ainda. É o que linguagens novas como o Go do Google fazem: ele prefere baixar o código fonte das dependências e compilar e linkar tudo estaticamente. Tem algumas exceções que vou falar depois mas na maior parte do tempo todo binário de Go você pode gerar na sua máquina, copiar num servidor e na maior parte do tempo tudo funciona sem se preocupar em precisar instalar dependências dinâmicas.

Mas se é tão simples assim e ficar se preocupando com versões de bibliotecas dinâmicas ou se elas estão instaladas é tão chato, porque não compilar tudo estaticamente? Primeiro porque você não precisa ter o código fonte das bibliotecas, só o arquivo de cabeçalho que lista suas funções. Segundo, porque digamos que você tenha 10 programas, todas elas linkam estaticamente uma biblioteca de criptografia. Digamos que descobrimos que tem um bug de segurança crítico nessa biblioteca. Se todos os programas estiverem linkados dinamicamente, basta recompilar só a biblioteca de criptografia e substituir na máquina em cima da antiga e todos os 10 programas automaticamente vão carregar a versão corrigida. Caso contrário, você agora precisa recompilar todos esses 10 programas pra que eles gerem novos binários com as correções e substituir na máquina um a um. Tudo sempre depende de pra que serve seu programa. Aliás, programação é muito isso: aprender a fazer escolhas entre opções que não estão erradas, que você precisa saber o contexto. Nem sempre as escolhas são simples, vá se acostumando com isso. É como em arte, qual cor é melhor? Vermelho? Azul? Amarelo? Não existe cor certa. Existe saber usar a cor certa no lugar certo.

Na maioria das distribuições Linux existe o conceito de um gerenciador de pacotes. No Redhat tempos o RPM e a ferramenta yum, em distribuições derivadas de Debian como o Ubuntu tempos DEB e a ferramenta apt. Em Arch Linux e derivados como Manjaro temos o pacman. E assim por diante. Mas quando não existe o pacote de algum programa que queremos você normalmente baixa um tarball que é tipo um zip, e vai precisar descomprimir, compilar e instalar a partir desse código fonte. E você normalmente vai ver a seguinte sequência de 3 comandos que precisa executar na linha de comando. Primeiro tem o ./configure. Configure é responsável justamente por checar se você tem todas as dependências que se precisa pra compilar já instalados na sua máquina, se não tiver ele vai dar erro e você precisa instalar as dependências e rodar configure de novo. Daí você roda make. Make lê um arquivo chamado Makefile que vem no projeto e que declara tarefas que a ferramenta make pode executar, e o default ou padrão é compilar. Ele vai chamar compiladores como o GCC ou CLANG e transformar o código C em binários. E por fim você tem o make install que é o make chamando a tarefa install do Makefile que vai pegar esse binário que acabou de compilar, dar permissão de executar, e mover pra um diretório que o PATH consiga encontrar, como o /usr/local/bin ou pra outro lugar arbitrário tipo /opt/blabla e colocar esse diretório no PATH se precisar. Toda vez que você faz essa sequência vê como demora.

Programas modernos são dependentes de dezenas de bibliotecas e o compilador tem bastante trabalho pra transformar centenas de arquivos ponto C em ponto O e depois no binário final linkado estaticamente ou dinamicamente com as bibliotecas que precisa, e só aí finalmente você pode executar seu programa. Em Windows você quase nunca instala programas assim, você normalmente já baixa o binário que vem embutido num instalador. O instalador normalmente instala as dependências que precisa. Em Windows, ao contrário de Linux, preferimos manter todas as versões de uma determinada biblioteca no sistema. Por isso você tende a ter um sistema operacional mais gordo que o Linux e com bibliotecas bem antigas ainda disponíveis, mas a vantagem é que conseguimos pegar um binário feito pra Windows 98 e ele vai provavelmente funcionar no Windows 10. MacOS é um híbrido dos dois conceitos, ele mantém muitas bibliotecas antigas até um certo ponto, mas não tão longe quanto o Windows, e ao contrário do Windows ele trás já os compiladores e dá a opção de compilar direto do código-fonte mantendo os cabeçalhos de bibliotecas que precisam pra compilar. Por isso existem adaptações de programas de Linux que funcionam perfeitamente em MacOS.

Você vai ver mais sobre esse tipo de escolha quando estiver usando linguagens que compilam em binários nativos, como C, C++, Pascal ou Object Pascal do Delphi, Objective-C ou Swift pra iOS e até em linguagens novas como o Google Go que eu falei a pouco e o Rust da Mozilla. Mas do outro lado espectro existe o conceito de um interpretador. Um interpretador é em si só um programa mas a função dele é ler código que você escreveu e traduzir em instruções pra máquina sem necessariamente precisar compilar esse código em binário nativo. Ou seja, ele depende de ter o código fonte do seu programa e traduzir, ou interpretar, o código toda vez que carrega.

Ele contém partes de um compilador, como o parser. Pra interpretar o código que você escreveu, precisamos de um programa que leia esse código e interprete de acordo com uma gramática da linguagem e traduza isso em instruções de máquina ou uma representação similar, pra isso serve o parser. Aqui que a coisa começa a ficar confuso, mas vamos focar pra tentar entender. Originalmente um interpretador é só um programa como eu já disse, você executa na linha de comando e passa como argumento o caminho pra um arquivo de texto que contém o código que você escreveu na linguagem que esse interpretador entende. Ele vai carregar esse arquivo, vai usar o parser pra checar que você escreveu certo, vai transformar numa representação interna que ele entende, normalmente numa árvore - lembra que eu falei no episódio anterior da importância de você entender estruturas de árvores? E finalmente vai começar a interpretar e executar as instruções pra fazer o que você pediu. Quando terminar seu programa, o interpretador também termina.

É assim que funcionam originalmente linguagens como Perl, PHP, Python, Ruby, até Javascript. Elas variam em algumas funcionalidades, por exemplo, Perl e Python permitem pré-compilar seu código fonte na representação intermediária que eu falei. O termo “compilar” pode confundir porque você pode achar que ele está compilando num binário nativo como o compilador de C, mas é diferente. Ele apenas vai economizar o tempo de ler o arquivo texto e parsear na tal estrutura de árvore que num Ruby chamamos de AST ou Abstract Syntax Tree. Por isso em Perl você tem a extensão ponto PL pro código fonte e ponto PLX pro pré-compilado ou em Python você tem ponto PY pro código fonte e ponto PYC pro pré-compilado. Ruby e Javascript não tem essa funcionalidade ainda. Na prática tanto faz, hoje em dia os HDs são rápidos o suficiente pra não importar tanto isso.

Antigamente fazia sentido porque a principal função de um interpretador era permitir que você pudesse criar programas de linha de comando que executam e terminam muito rápido. Você podia chamar de duas formas: digitando perl e passando como argumento o arquivo do código ou ligando o flag de execução direto do arquivo texto de código e tendo como primeira linha o que chamamos de “shebang” que é a cerquilha com exclamação e o caminho absoluto pro executável do interpretador. E se você já fuçou pela Web por scripts deve ter visto isso. Existia ainda outra opção que era criar um pacote que embute o interpretador junto com a representação do seu código fonte num único executável. O Perl permitia isso e é o que o Visual Basic antes da versão 6 também fazia. No final parecia que era um binário nativo como de um C, mas na verdade era um pacote que embutia as duas coisas. De curiosidade eu disse Visual Basic até o 6 porque no 6 você tinha a opção de compilar o código fonte em binário nativo quando ele passou a adaptar e usar o compilador do Visual C++. Visual Basic 6 é um produto que me impressionou bastante na época.

Como eu disse antes, um interpretador originalmente foi feito pra iniciar rápido e terminar rápido. No episódio anterior eu comecei a falar sobre servidores web. Todo sistema operacional lida com processos. Toda vez que você executa uma aplicação, seja o executável nativo compilado em C ou os interpretadores que citei antes que também são feitos em C ou C++, o trabalho do sistema operacional é carregar, alocar memória e criar um processo em execução. Em ambientes UNIX ou principalmente Linux criar processos é uma coisa relativamente barata, ordens de grandeza se comparar com MacOS ou Windows. Em Windows criar processos envolve mais peso ou o que chamamos de overhead, não é uma coisa barata e nem é porque ele é pior mas porque tem requerimentos diferentes. Pensando antigamente num UNIX, um programador naturalmente faria um programa que é executado e, se for um servidor TCP, ele só consegue servir um cliente TCP de cada vez, como já expliquei no episódio anterior.

Pra conseguir servir múltiplos clientes TCP ao mesmo tempo, a coisa mais fácil de se fazer é um FORK do processo, ou seja, criar uma cópia do programa em execução na memória. Cada nova conexão o servidor faz um novo FORK. E esse fork é razoavelmente rápido de criar e usa pouca memória porque ele reusa os mesmos bits do programa pai original que também está na memória, é um truque do sistema operacional e esse recurso é chamado de COW ou Copy on Write que evita fazer uma cópia inteira do executável da memória pra outro espaço de memória. Então se um processo precisa de 200 kbytes em memória, no instante que fizer fork ele não vai dobrar a memória e usar 400, vai usar muito menos que isso.

Um servidor web como o Apache antigo que eu falei, no começo só usava FORK. Por isso ele era simples. E mais do que isso, ganhou a capacidade de executar programas dependendo da URL que era pedido, através do padrão que falei que ficou conhecido como CGI. No começo esses executáveis eram só programas nativos compilados de C. Mas falei que rapidamente fizeram Perl funcionar no Apache também e começamos a era da web dinâmica. Pois bem, interpretadores com programas curtos iniciam e terminam rápido. Mas se o programa vai ficando mais e mais complicado, incluindo coisas custosas como conectar num banco de dados ou manipular arquivos, ou carregar muitos dados em memória, é muito custoso ficar iniciando e terminando esses programas toda vez. Foi quando surgiu a primeira gambiarra na forma de coisas como o mod_perl e depois o mod_php que são módulos compilados dentro do Apache e que dá inclusive acesso às estruturas internas do Apache. Toda vez que um novo fork do Apache é feito ele já pré-carrega com a capacidade de executar Perl ou PHP sem precisar carregar o interpretador separadamente. Isso é mais rápido do que CGI puro, porém é um terrível buraco de segurança já que bugs no Perl ou no PHP dão acesso ao Apache inteiro e a tudo que ele tem acesso na máquina.

A segunda gambiarra pra evitar ter que carregar os interpretadores a cada nova requisição foi o FastCGI que é basicamente um segundo servidor que carrega paralelo ao Apache ou outro servidor web, ele mantém o interpretador e seu programa ativos em memória e pode servir múltiplas requisições sem precisar terminar e reiniciar toda vez. Daí o servidor web e o servidor de FastCGI se comunicam via TCP ou outras formas como Unix Sockets que é recurso em Unix para que dois processos consigam se comunicar. Quando uma requisição chega ao Apache ele passa essa informação e outros metadados pro servidor de FastCGI executar o programa Perl ou PHP e devolve a resposta resultante de volta pro Apache devolver pro navegador. Uma grande vantagem disso era poder isolar os dois servidores de forma que um não invada demais o espaço do outro em caso de bugs de segurança.

Mas em Windows, você não tem o conceito de FORK na API de alto nível do sistema, o CreateProcess. Internamente o kernel do Windows permite algo parecido com FORK incluindo o recurso de COW ou Copy on Write, mas não é tão usado e mesmo assim ainda é uma ordem de grandeza mais lento. Isso explica porque os primeiros Apache eram uma porcaria em Windows, nem perto da performance num Linux e por isso dávamos preferência a usar o próprio IIS da Microsoft. Só porque dois programas compilam pra dois sistemas operacionais diferentes e rodam, não significa que rodam igual. Coisas feitas pra Windows funcionam melhor em Windows e coisas feitas pra Linux só rodam bem em Linux. Lembre-se disso. Dada essa limitação você acaba sendo obrigado a usar mais Threads que são linhas de execução em paralelo dentro de um mesmo processo.

Já falamos da discussão de compilação estática vs compilação dinâmica. É hora de falar de outro dilema da programação: programação baseada em processos com fork versus um processo com múltiplas threads. Hoje em dia usamos muito mais Threads mas processos com fork não estão obsoletos, por exemplo, o conector do banco de dados PostgreSQL funciona via fork assim como muitos outros softwares. Como eu disse antes, toda vez que você executa um programa o sistema operacional carrega os bits em memória e inicia uma entidade chamada processo. O sistema operacional tem o poder de matar esse processo ou pausar e reiniciar sua execução. Dentro do processo ele não tem consciência de todos os outros processos ao seu redor a menos que tenha privilégios de perguntar ao sistema operacional. Pra todos os efeitos, ele se sente como se fosse a única coisa rodando na máquina. Quando o processo precisa escrever na memória ele não escreve diretamente.

Pense na memória como um livro com páginas numeradas sequencialmente de 1 até um número grande. Num computador de 32-bits temos a capacidade de mapear de 1 até 2 elevado a 32 ou seja mais de 4 bilhões ou 4GB. Num computador de 64-bits temos a capacidade de mapear até 2 elevado a 64 ou seja mais de 18 quintilhões de bytes ou 18 exabytes, que é bastante coisa. Se tem vários programas na memória você imaginaria que o certo seria cada programa começar a partir de uma determinada página dessa memória, tipo o Word começando na página 1, o Excel na página 100, o Chrome na página 200 e assim por diante, mas isso seria extremamente complicado de controlar. Em vez disso todo mundo começa na página 1, mas de uma memória virtual e o sistema operacional que se encarrega de traduzir a página 1 da memória virtual do Excel pra página 100 da memória real. Então o Excel nunca teria como acessar a memória do Chrome. E assim começamos a criar um mínimo de segurança entre os programas. No MS-DOS antigo que rodava no que chamamos de modo real, cada programa tem acesso a todos os endereços reais da máquina, mas não tinha problema porque no MS-DOS só dava pra rodar um programa de cada vez. Pra trocar do Word pro Excel, tinha que primeiro sair do Word e daí carregar o Excel. Mas hoje em dia onde vários programas.

Isso facilita muito pra nós programadores porque não precisamos nos preocupar com a memória de terceiros. E se precisamos fazer algo como um servidor Web onde cada conexão de um cliente TCP precisa rodar em paralelo a outras conexões, podemos fazer só um FORK do processo pra cada um. Cada FORK é um processo isolado e daí um não afeta a memória do outro. Tudo funciona perfeitamente. Mas se é caro, ou não é possível fazer FORKS, a única outra solução é dentro de um único processo iniciarmos múltiplas Threads, que são bem mais baratas que forks. Cada Thread daí pode servir um cliente TCP conectado ao processo. Mas agora começamos a ter problemas. Porque não existe memória isolada pra cada thread, dentro do processo cada thread tem acesso a toda a memória virtual do processo. Por isso você vai ouvir falar, caso ainda não tenha passado por isso, que escrever programas que são multi-thread é uma puta dor de cabeça, porque você precisa garantir o que chamamos de thread-safety ou seja, código seguro pra rodar em threads. Nesse caso, você como programador tem a responsabilidade de escrever código que, quando executado numa thread, não pise sem querer na memória de outra thread executando ao mesmo tempo. A forma de fazer isso normalmente envolve toda vez que você precisa escrever algum dado em memória, primeiro você notifica o sistema que vai escrever, pega o que chamamos de um lock, escreve e depois libera esse lock. Se falhar em pegar o lock, se arrisca a corromper a memória de outra thread. Se esquecer de soltar o lock vai causar um deadlock e bloquear outras threads de pegar o lock. Pense essa rotina em milhares de linhas de código. Ou seja, dor de cabeça.

Recapitulando, um programa binário é executado pelo sistema operacional num espaço de memória isolado, chamado processo. Cada processo pode ser clonado via fork num Linux. E cada processo pode rodar uma ou mais threads dentro dele. Um servidor web é um processo e pra servir múltiplas requisições ele faz um fork pra cada uma. Daí nasce CGI ou FastCGI. Mas em Windows como forks são ordens de grandeza mais caros, o servidor web IIS precisa de outro truque e isso veio na forma do padrão ISAPI que é como se fosse um programa escrito pra CGI mas com modificações pra ser thread-safe e compilado dentro de uma biblioteca DLL que é carregado pelo IIS. O servidor web da Netscape, que não existe mais, tinha outro padrão similar, o NSAPI. Muitos programadores de Delphi já devem ter feito programas pra Web e compilado em DLLs ISAPI ou NSAPI.

Com o advento de coisas como CGI, FastCGI, ISAPI, NSAPI e outras arquiteturas e o fato de termos interpretadores que possibilitam escrever código que tem pouca dificuldade de rodar porque não precisamos nos preocupar com compilação e gerenciamento de dependências, é quando linguagens interpretadas como Perl, PHP e ASP Clássico ganham notoriedade no mercado, especialmente com o timing do início da bolha das ponto coms a partir do meio dos anos 90.

Voltando a threads mais um pouco, como faz pra várias threads rodarem em paralelo? Pense assim, um processo tem pelo menos 1 thread. Pra um processo executar múltiplas threads ao mesmo tempo, o CPU tem uma coisa chamada agendador ou scheduler, mas só pode rodar um número limitado de threads ao mesmo tempo. Então ele pausa uma thread em execução de acordo com vários critérios e acorda outra thread que tava pausada pra dar chance dela rodar e vai fazendo isso com os vários threads de tal maneira que você tem a impressão que estão todos rodando ao mesmo tempo. E quando você pede o tal lock é esse scheduler que vai evitar que outras threads pisem no seu calo por exemplo. Programação multi-thread vai ser uma longa jornada pra vocês que estão iniciando e é até hoje considerada uma das coisas mais difíceis pra um programador realmente entender, só competindo com gerenciamento de memória e segurança.

Entendendo esses conceitos básicos que eu expliquei muito rapidamente, podemos começar a entender coisas como o surgimento do Java. Você precisaria ter tido experiência com C++, Smalltalk e Lisp pra entender realmente como um Java nasce mas vamos começar por aqui. Em 1991 surge uma nova linguagem na Sun com o codenome Oak, e o objetivo inicial era ser um sistema pra set-top-boxes. Naquela época se tinha o conceito que as pessoas teriam “caixas” multimidia nas salas integrando a tv a cabo e outros conteúdos. Esse conceito anos depois viria a se tornar um Tivo ou Roku ou mesmo Google Chromecast e Apple TV de hoje, que inclusive seriam substituídos pelas Smart TVs e consoles de videogame também. Lembrem-se que Linux ainda estava só no começo, ainda era rudimentar, e coisas como Android estão muitos anos à frente ainda.

Java embute alguns conceitos de compiladores e interpretadores. Mais especificamente de máquinas virtuais, por isso você tem a JVM ou Java Virtual Machine. Uma máquina virtual é uma evolução de um interpretador. Em vez de ser só um programa que executa código escrito numa determinada linguagem, como Java, a JVM tem uma ambição maior, ele quer ser o próprio sistema operacional e abstrair a máquina real por baixo. Pro programa em Java não existe um sistema operacional Linux ou Windows rodando num processador Intel ou ARM. Só existe a JVM.

O conjunto todo que a Sun inventou tem várias ferramentas. Diferente de um interpretador e mais similar a uma linguagem compilada como C++, ele separa a fase de compilação da fase de execução. O compilador JAVAC pega seu código fonte escrito em Java (eles usaram o mesmo nome pra tudo e isso dificulta explicar, eu sei) e compila numa representação intermediária que chamamos de Java Byte Code. Na prática, lembra que eu falei que o compilador de C traduz seu código C em instruções nativas do sistema operacional e do processador? E se seu sistema operacional e processador forem a JVM? Também falei que cada processador hardware vem com um conjunto particular de instruções que só roda nesse processador. No caso da JVM é a mesma coisa, é isso que chamamos de bytecode. Então quando o JAVAC compila, ele gera um binário nativo só que específico pra máquina virtual Java. Na época se pesquisou inclusive fazer um processador hardware mesmo que entendia essas instruções mas nunca foi mesmo pra frente. Entenderam? A JVM é como se simulasse um computador.

Assim como um interpretador, você carrega a JVM e aí ele carrega o seu programa, abstraindo o sistema real por baixo. Mas diferente de um interpretador ele faz coisas mais pesadas e tem premissas mais caras. Java é péssimo pra ser algo como substituto de Perl na linha de comando porque a inicialização dele é extremamente lenta até hoje. Isso porque ele tem um gerenciador de memória muito mais sofisticado do que a maioria dos interpretadores e carrega muito mais coisas na inicialização. Por causa disso também ele não é bom pra fazer FORK pra rodar em múltiplos processos, porque o custo desse gerenciador de memória é extremamente alto. Como simulador de um sistema operacional, nesse sentido ele se parece mais com o Windows do que um Linux. Então o caso de uso ideal do Java é rodar um único processo, sozinho na máquina, e seus programas rodam em paralelo dentro da JVM e a JVM substitui o sistema operacional pra gerenciar seus programas rodando em paralelo. E cada programa seu precisa usar Threads pra executar coisas em paralelo dentro dele. Viu porque eu disse que a JVM praticamente substitui o sistema operacional? Você tira o máximo dele quando não roda mais nada além da JVM e dá todos os recursos da máquina pra ele gerenciar.

E eu disse que compiladores como C fazem dezenas de otimizações pra reescrever seu código da maneira que rode mais rápido possível. A JVM vai um passo além, ele fica medindo como seu programa roda e otimiza em tempo real pra binário nativo da máquina por baixo pra ficar mais rápido. É a técnica que chamamos de JIT ou compilador Just In Time. Javascript faz algo semelhante em navegadores modernos como Safari ou Chrome. Um otimizador em tempo de compilação demora Muito pra compilar, se você já tentou compilar um programa manualmente, dependendo do tamanho já viu que pode levar de segundos a minutos. Uma página web não pode demorar tanto pra carregar. Em vez de tentar otimizar tudo, ele só lê o código fonte em Javascript e faz o mínimo pra interpretar e executar, mas à medida que ele executa um compilador JIT vai otimizando seu código em tempo de execução e só o código que está executando. Se você por acaso carregou uma biblioteca que nunca é usada, o compilador JIT nem vai perder tempo tentando otimizar esse pedaço. Então é tipo um meio termo entre um compilador AOT ou Ahead of Time como em C e um interpretador.

A vantagem do Java compilar num binário intermediário de bytecode e rodar numa JVM é que eliminamos o problema de ter que ter compiladores pra cada arquitetura de computador como Intel ou ARM. Basta que cada tipo de computador e sistema operacional tenha uma JVM. Você já deve ter visto isso com programas Java que rodam em Mac ou Linux ou Windows, mas em cada um você precisa baixar um Java específico uma vez só. Além disso o Java cresceu rapidamente e temos quase tudo escrito em Java. Assim como uma das maiores idéia do C era sua portabilidade para diferente arquiteturas, contanto que você recompilasse o código C, no caso do Java eles também queriam portabilidade escrevendo o máximo possível de tudo somente em Java.

Aliás, essa é outra diferença do Java com interpretadores. Em Java, você tem praticamente tudo que um sistema operacional como Windows ou Linux oferecem mas escritos todos em Java e embutidos dentro da JVM. Em interpretadores como Perl ou Python ou PHP ou Ruby eles dependem bastante do sistema operacional por baixo. Interpretadores de código aberto como eles nunca tiveram a ambição de substituir o sistema operacional, mas de se integrar a eles. Pra fazer isso usamos o conceito de binding, que é uma forma do interpretador expor a uma biblioteca de código nativo o acesso a suas estruturas internas e sua memória. Então, em vez de escrever uma biblioteca de criptografia toda em Python, ele simplesmente mapeia para o que o OpenSSL já instalado no sistema operacional oferece. Perl, PHP e Ruby fazem a mesma coisa. Isso inclusive cria um problema: se a biblioteca nativa que você está fazendo binding não for thread-safe, você bloqueia o interpretador de rodar threads em paralelo. Isso acontece o tempo todo em todos os interpretadores.

Por isso também é mais difícil de fazer interpretadores rodarem em todos os sistemas operacionais. Muitas vezes eles rodam, mas algumas funcionalidades são diferentes ou tem qualidade inferior porque as mesmas dependências não existem em todos os sistemas operacionais. Por isso mesmo, nunca faça programas complexos que rodam num interpretador no Windows esperando que vá funcionar igualzinho em Linux ou Mac. Entre Mac e Linux existe mais compatibilidade porque ambos compartilham muitas das mesmas bibliotecas open source. No Windows elas precisam ser remendadas pra se ligar a bibliotecas proprietárias que só existem no Windows. Portanto, se vai codar em linguagens interpretadas como essas, prefira sempre Linux ou Mac. Não é uma questão de ser anti-Microsoft ou algo assim, os interpretadores que foram criados em Linux funcionam bem só em Linux, mesmo que exista uma versão pra Windows.

Macs passaram a ser construídos sobre um UNIX baseado na Kernel do BSD UNIX quando o OS X saiu em 1999, sistema esse que foi uma evolução do NextStep criado no fim dos anos 80 depois que Steve Jobs foi removido da Apple. Ele é um UNIX de verdade, assim como o Solaris da Sun ou o HP-UX. Eles se parecem com distribuições Linux porque o Linux tentou ser um clone de UNIX. E eles são razoavelmente compatíveis porque os UNIX modernos e distribuições Linux usam mais ou menos as mesmas ferramentas gerais, incluindo o mesmo compilador GCC e suas dependências como o GLIBC. Só o Windows que é completamente diferente dentre os principais sistemas operacionais. E o Java nasceu com a promessa de permitir que você pudesse escrever programas que rodavam em qualquer lugar, porque havia JVM para Linux, para Solaris, pra Mac, pra Windows. Por isso ele se popularizou tanto tão rápido. E também coincidiu com o começo da bolha da internet que acelerou ainda mais a adoção de Java.

Vendo isso, a Microsoft não quis perder o bonde. Primeiro, aproveitando a experiência com Visual Basic e Visual C++ que compilavam binários nativos compatíveis com Windows, eles contrataram o Anders Hejlsberg, criador do Turbo Pascal e do Object Pascal do Delphi da Borland. Seu primeiro produto foi o Visual J++ que era uma versão de Java da Microsoft que compilava pra uma JVM proprietária e com várias funcionalidades incompatíveis. Por exemplo, notável era que o Java também tinha um jeito de fazer bindings pra binários nativos chamado JNI mas a no J++ a Microsoft fez uma forma diferente e discutivelmente melhor ou mais performática chamada RMI e o J++ fazia vários atalhos pra se ligar ao Windows de forma mais eficiente. A linguagem tinha a mesma cara do Java mas não era compatível com o Java da Sun. Por conta disso os programas compilados em J++ rodavam de forma mais eficiente só que o bytecode era incompatível com a JVM da Sun e como pra se chamar Java precisava ser compatível a Sun processou a Microsoft. O J++ acabou sendo descontinuado por conta disso mas as suas sementes é que deram origem ao que você conhece como o CLR o Common Language Runtime, ao framework .NET e à linguagem C#. Por isso as primeiras versões de C#, pra mim, tinham cara de Java misturado com Delphi.

Veja, como a JVM executa bytecodes binários, qualquer linguagem pode ser transformada nesses bytecodes. A sintaxe que conhecemos como Java é só um deles. Por isso hoje em dia você tem linguagens como Scala, Clojure e Kotlin que compilam para o mesmo bytecode e rodam na JVM. Mas a Microsoft usou essa característica antes e além de C# já fez também Visual Basic.NET e ambas compilavam pro bytecode do CLR. O CLR ao contrário da JVM fazia muitos bindinds direto pro sistema operacional pra conseguir ter a melhor performance. Portabilidade não era importante porque bastava rodar onde o Windows rodava, e nessa época a Microsoft ainda considera o mundo open source como inimigo, coisa que só veio a mudar nos anos recentes. Essa é a grande diferença entre Java e .NET. Embora ambas sejam máquinas virtuais e os códigos compilem em bytecodes, o Java tinha como meta rodar no maior número de dispositivos quanto possível e o .NET só em Windows.

Mas as coisas foram mudando. Essa ambição da Sun se mostrou muito a frente do seu tempo. A Sun teve muitas ingerências, perdeu valor e foi comprada pela Oracle e desde antes disso com a depressão depois do crash da bolha da internet em 2001 que ela evolui a passos de tartaruga. Em paralelo no mundo open source, surgiu uma iniciativa ousada. A Microsoft abriu a linguagem C# como um padrão aberto Ecma, então Miguel de Icaza, do projeto GNOME decidiu tentar implementar o CLR no Linux, esse projeto recebeu o nome de Mono. Levou quase 10 anos pra conseguir de fato criar uma CLR e todo o framewortk quase totalmente compatível com o da Microsoft. Porque não era só uma questão de fazer a máquina virtual e o compilador, era necessário também reescrever do zero todas as bibliotecas que compõe o pacote .NET framework. E muitas delas dependem de bindings para bibliotecas nativas do Windows como já falei. Isso foi um dos maiores problemas pra conseguir compatibilidade no Linux. Por exemplo, as bibliotecas para fazer aplicativos desktop, tipo um Word da vida, usam bibliotecas nativas do Windows pra gerar janelas nativas e não janelas diferentes como o Java fazia com seu toolkit Swing. Do jeito do Java o peso era maior e a performance era menor. Mas usando bibliotecas nativas a velocidade do C# e do .NET pra desenhar as mesmas janelas era muito maior e mais comparável com o que o Visual Basic ou Visual C++ anterior faziam. O Mono no Linux fazia bindings com o GNOME pra também conseguir ter performance similar.

Depois de muitos anos e com a troca de CEO com a saída de Steve Balmer e a entrada de Satya Nadella e sua política de boa vizinhança, a Microsoft se tornou finalmente mais amigável ao mundo open source e acabou comprando a empresa de Miguel De Icaza, a Xamarin. E o Mono foi renomeado como .NET Core e hoje está em caminho de substituir o antigo .NET framework. E apesar do CLR suportar múltiplas linguagens na prática todo mundo programa ou em C# ou em Visual Basic.NET mesmo.

E pro episódio de hoje acho que já cobrimos bastante terreno. Eu tentei explicar um pouco sobre como as coisas funcionam por baixo dos panos. A idéia de CPUs, sistemas operacionais, gerenciamento de processos e de memória. As diferenças entre linguagens compiladas de forma estática e dinâmica. O que são interpretadores e o conceito de máquinas virtuais e bytecodes. E também as diferenças de rodar coisas em paralelo com forks e com threads. Os anos 90 foram o terreno para o crescimento de Java e .NET no mundo mais proprietário e em paralelo pra evolução do mundo open source como um todo, do Linux, e linguagens interpretadas como Perl e PHP. Estamos ainda só começando o século XXI. No próximo episódio vamos precisar do que expliquei aqui pra você finalmente começarem a entender as diferenças entre todas as linguagens modernas.

Como eu já disse no episódio anterior, se eu fiz meu trabalho direito espero que vocês tenham ficado com mais dúvidas do que quando começaram a assistir. Mandem suas dúvidas nos comentários, se curtiram o vídeo mandem um joinha, compartilhem com seus amigos, não deixem de assinar o canal e clicar no sininho. Aguardem a parte 2 sobre tecnologias back end na semana que vem, a gente se vê, até mais!

tags: back-end java .net c# clr jvm linux unix windows assembly compilador interpretador ruby python perl php apache golang akitando

Comments

comentários deste blog disponibilizados por Disqus