Tudo ao mesmo tempo agora: paralelismo com C#

Há cerca de um mês, tive o prazer de apresentar um webinar da Pricefy, falando sobre programação assíncrona com C# .NET. Na ocasião, apresentei conceitos fundamentais de multithreading, I/O assíncrono, Task-based Asynchronous Programming, mostrei como async/await funciona por baixo dos panos e finalizei com código (é lógico!), mostrando exemplos de mau uso de async/await e como usar isso do jeito certo. O conteúdo está bem didático, tenho certeza que mesmo quem não é da turma do C# pode aprender uma coisa ou duas.

https://www.youtube.com/watch?v=ywpEtLht6So

Uma coisa é uma coisa; outra coisa é outra coisa

Durante o webinar, fiz questão de deixar claro que há uma distinção entre o que são tarefas assíncronas e o que são tarefas paralelas. Muito embora, sejam frequentemente usadas em conversas corriqueiras como sendo a mesma coisa, elas não são a mesma coisa.

Obviamente que uma discussão exaustiva sobre o assunto está fora da agenda deste post. Mas vou fazer uma nano desambiguação aqui, para então seguir com o assunto alvo deste post.

Consideramos paralelismo quando temos uma tarefa que leva um certo tempo para ser concluída e desejamos completá-la em menos tempo. Para isso, o requisito basilar é que a tal tarefa seja passível de ser dividida em múltiplos pedaços iguais (ou bem próximo disso) e que se tenha um número de unidades de trabalho de igual capacidade, para que possam trabalhar ao mesmo tempo e com equivalente desempenho.

Um exemplo cotidiano de paralelismo seria dividir a tarefa de descascar 3 kg de batatas entre três pessoas de igual habilidade (e força de vontade!). Digamos que você sozinho leve 3 horas para concluir a tarefa. Okay, o problema é que o jantar é daqui há 1 hora e meia. O que fazer? Dividir a tarefa com aqueles dois amigos que estão sentados no sofá, sem fazer nada, enquanto você prepara tudo sozinho? Sim, essa é uma ideia. Se os dois tiverem a mesma habilidade de descascar batatas que você tem, em aproximadamente 1 hora a tarefa estará concluída e você poderá partir para a próxima ⎼ assar, cozer, fritar, ou o que quer que seja.

Em termos de software, o princípio é o mesmo. Digamos que você tenha, por exemplo, uma lista com 300 itens e tenha que realizar uma determinada operação em cada um deles. Se você tiver três CPUs em seu computador, você pode dividir a lista em três e processar ⅓ em cada CPU.

“Most modern CPUs are implemented on integrated circuit (IC) microprocessors, with one or more CPUs on a single metal-oxide-semiconductor (MOS) IC chip. Microprocessors chips with multiple CPUs are multi-core processors. The individual physical CPUs, processor cores, can also be multithreaded to create additional virtual or logical CPUs.” — Wikipedia.

Note que eu disse “CPU” e não “thread”. Isso porque o paralelismo “de verdade” é obtido com múltiplas CPUs executando threads ao mesmo tempo e não com múltiplas threads sendo escalonadas por uma única CPU. Com múltiplas threads em uma única CPU, temos o que é conhecido como multitarefas “virtualmente simultâneas”.

Por exemplo, quando eu digito algo no teclado do meu computador e como amendoins “ao mesmo tempo”, no grande esquema das coisas, digamos, em uma janela de 10 minutos, alguém pode dizer que estou comendo amendoins ao mesmo tempo em que digito coisas no computador; mas na real, encurtando essa janela de tempo, é possível ver que eu não faço as duas coisas exatamente ao mesmo tempo, mas sim, intercaladamente, mudando de uma tarefa para a outra a cada certo intervalo.

É basicamente assim que as threads funcionam: elas são como “unidades de processamento virtuais”, que uma CPU executa com exclusividade por 30 milissegundos cada. Humanamente falando, imagino que seja impossível perceber essa mudança de contexto; por isso, tudo parece realmente simultâneo para nós.

Assíncrono vs Paralelo

Já no caso da assincronicidade, consideramos assíncrono aquilo que não vai acontecer do início ao fim exatamente agora, que pode ter um curso intermitente, e não queremos ficar sem fazer nada enquanto esperamos por sua conclusão, que acontecerá em algum momento futuro. Isso é tipicamente comum com operações de I/O.

Operações de I/O não dependem apenas de software, obviamente, mas invariavelmente de dispositivos de I/O (a.k.a. hardware), que de algum modo compõe ou complementam um computador, cada qual com seu modo de funcionar, seu tempo de resposta e outras particularidades quaisqueres. Alguns dos dispositivos de I/O mais comuns são: HD, monitor de vídeo, impressora, USB, webcam e interface de rede. Qualquer programa de computador que valha seu peso em sal executa alguma operação de I/O ⎼ mostrar “hello world” em uma tela, salvar um texto qualquer em um arquivo, iniciar um socket de rede, enviar um e-mail, etc.

Voltando à cozinha para mais um exemplo, digamos que o jantar de hoje seja macarrão à bolonhesa e salada verde com tomates cerejas e cebola. Como poderia acontecer a preparação desse cardápio? Bom, eu poderia fazer uma coisa de cada vez, de modo sequencial. Ou poderia tentar otimizar um pouco meu tempo, minimizando o tempo que fico sem fazer nada, aqui e ali, esperando por algum “output” qualquer.

  1. Eu começo colocando uma panela de água para ferver, onde vou cozer o macarrão. Enquanto ela não ferve, eu corto cebola, alho e bacon para refogar a carne moída;
  2. Terminando, a tarefa de pré-preparo, sequencialmente, enquanto ainda espero a água para o macarrão chegar à fervura, começo então a preparar a carne moída ⎼ refogo, coloco temperos diversos, extrato de tomate e deixo cozer em fogo médio;
  3. Vejo que a água começou a ferver, então, acrescento um tanto de sal e ponho o macarrão para cozer. Okay, agora, enquanto o macarrão cozinha por aproximadamente 8–10 minutos e a carne moída também está cozendo, apurando o sabor, o que eu faço? Sento e espero? Não! Ainda tenho que preparar a sala;
  4. Lavo as folhas verdes, os tomates, corto a cebola, junto tudo em uma saladeira (trriiimmm!!!) ouço o alarme indicando que o macarrão está cozido e é hora de escorrê-lo rapidamente, mesmo que tenha que parar o preparo da salada, momentaneamente, afinal de contas, só falta temperar e isso não é algo tão crítico, pode acontecer daqui um pouco; já o macarrão, precisa ser escorrido agora!
  5. Escorro o macarrão, coloco em uma travessa de macarronada, despejo a carne moída por cima, misturo cuidadosamente e finalizo ralando uma generosa quantidade de queijo parmesão por cima;
  6. Levo a travessa de macarronada para a mesa de jantar, volto à cozinha, tempero a salada rapidamente e levo para mesa também;
  7. Tá na mesa, pessoalll!!!

Vê como tudo aconteceu de modo predominantemente assíncrono, porque cada preparo teve seu tempo e sua prioridade? Tudo aconteceu de modo intercalado. Foram 40 minutos intensos, sem ficar um minuto parado sem fazer nada, mas aproveitei muito melhor o meu tempo.

É basicamente assim que funcionam as operações de I/O assíncronas: uma única thread é capaz de despachar milhares de operações de leitura ou escrita para os diversos dispositivos de hardware de um computador, conforme as requisições vão chegando; e enquanto as respostas dos dispositivos não chegam, indicando que as operações foram bem sucedidas ou não, elas vão atendendo a outras requisições; e assim seguem, em um loop semi-infinito.

Por que uma thread ficaria parada, bloqueada, esperando pela resposta de uma escrita em um socket, que pode levar certo tempo, enquanto poderia estar escrevendo algo em um arquivo no disco rígido? Essa é a magia do I/O assíncrono in a nutshell. Depois, assista à minha talk no YouTube, que lá eu me aprofundo mais no assunto; não quero me repetir aqui.

Como você provavelmente já notou, tanto a abordagem paralela, quanto a assíncrona, são maneiras de se implementar concorrência em uma aplicação, cada qual com sua finalidade. A primeira, envolve threads de execução em múltiplas CPUs simultâneas; a segunda se baseia em máquinas de estado, futures e callbacks, executando possivelmente em uma única thread.

Espero que essa introdução tenha sido suficiente para ficarmos todos na mesma página.

Paralelismo está no menu hoje

O assunto da vez hoje é paralelismo com C#. E para continuar se aprofundando no assunto, uma visão de alto-nível da arquitetura de programação paralela oferecida pela plataforma .NET.

Fonte: https://docs.microsoft.com/en-us/dotnet/standard/parallel-programming/

Eu acho importante começar com essa big picture, porque infelizmente, a plataforma .NET não ajuda muito com aquela distinção de assíncrono vs paralelo, que vimos há pouco. A razão disso é que a classe Task é o ponto de partida da Task Parallel Library (TPL) tanto para algoritmos assíncronos quanto para paralelos.

A documentação sobre Task-based Asynchronous Programming (TAP) define “task parallelism” como “uma ou mais tarefas independentes executando concorrentemente”, o que soa um pouco fora do que vimos há pouco.

Já a documentação sobre Parallel Programming in .NET (fonte do diagrama acima), por sua vez, diz que “muitos computadores pessoais e estações de trabalho têm vários núcleos de CPU que permitem que várias threads sejam executadas simultaneamente”, e então, conclui dizendo que “para aproveitar as vantagens do hardware, você pode paralelizar seu código para distribuir o trabalho entre vários processadores”. Isso faz mais sentido para mim.

Acredito que isso se dê pelo fato de que eles quiseram implementar uma abstração única de tarefas independentes em cima do modelo de threads, que são executadas a partir de um thread pool, que por sua vez, dispara a execução delas em um número de CPUs disponíveis ⎼ por padrão, o número mínimo de threads do ThreadPool é equivalente ao número de CPUs disponíveis no sistema.

O que vale é a intenção

Uma dica de ouro, que para mim ajuda bastante, é olhar para a classe Task sob a ótica daquilo que tenho a intenção de implementar.

  • Os membros .Result.Wait().WaitAll().WaitAny() são bloqueantes (a.k.a. síncronos) e devem ser usados somente quando se tem a intenção de implementar paralelismo. Tarefas paralelas estão relacionadas ao que chamamos de CPU-bound e devem ser criadas usando preferencialmente os métodos .Run().Factory.StartNew();
  • Além da palavra-mágica await, usada para aguardar assíncronamente a conclusão de um método assíncrono (na prática, uma instância de Task ou Task<T>), os métodos .WhenAll().WhenAny(), que não são bloqueantes (a.k.a. assíncronos), devem ser usados quando a intenção for implementar assincronicidade. Tarefas assíncronas estão relacionadas ao que chamamos de I/O-bound.

A propósito, TaskCreationOptions.AttachedToParent pode ser usada na criação de uma tarefa paralela (estratégia “divide and conquer” / “parent & children”), mas não por uma tarefa assíncrona. Tarefas assíncronas, de certo modo, já criam sua própria “hierarquia” via await.

Okay, vamos focar em paralelismo a partir daqui e ver um pouco de código.

Colocando a mão na massa

Como vimos há pouco, tarefas paralelas vão bem para implementar processos que sejam CPU-bound; ou seja, que levam menos tempo para conclusão em função do número de CPUs disponíveis para particionamento/execução do trabalho.

O código fonte dos exemplos está neste repositório aqui.

Podemos dividir esse cenário em duas categorias de processamento:

  • Estático — data parallelism
  • Dinâmico — task parallelism

Processamento paralelo “estático”

Chamamos essa categoria de estática, porque se trata de iterar uma coleção de dados e aplicar um dado algoritmo em cada um de seus elementos. Para isso, a TPL nos oferece três métodos a partir da classe Parallel.

1. For() ⎼ análogo ao for clássico

Repare nos parâmetros passados na invocação do método Parallel.For(), na linha 14. Além dos típicos limites inicial e final, há também uma Action<int, ParallelLoopState>, que provê o índice da iteração atual e um objeto com o estado do loop. É a partir deste objeto de estado que solicitamos um “break” no loop.

private static void ParallelFor()
{
var numberOfIterations = 1000;
var min = 0;
var max = 100_000_000;
var random = new Random();
var breakIndex = random.Next(1, numberOfIterations);
Console.WriteLine($"Generating random numbers from {min} to {max} over {numberOfIterations} iterations");
Console.WriteLine($"Random break index: {breakIndex}");
var result = Parallel.For(1, numberOfIterations, (i, state) =>
{
Console.WriteLine($"- Iteration #{i} > Begin at thread #{Thread.CurrentThread.ManagedThreadId}, task #{Task.CurrentId}");
// Has .Break() been called by another parallel iteration?
if (state.ShouldExitCurrentIteration)
{
// Is this current iteration greater then the one where .Break() was invoked?
if (state.LowestBreakIteration < i)
{
Console.WriteLine($"- Iteration #{i} > Will exit now <———–");
return;
}
}
int num;
// A naive lock for educative purpose only
lock (random)
{
num = random.Next(min, max);
}
// If it got to the break index, invokes .Break() to prevent further iterations
if (i == breakIndex)
{
Console.WriteLine($"- Iteration #{i} > Got to break index <———–");
state.Break();
}
Console.WriteLine($"- Iteration #{i} > End: {num}");
});
if (result.LowestBreakIteration.HasValue)
Console.WriteLine($"Lowest break iteration? {result.LowestBreakIteration}");
}
view raw parallel_for.cs hosted with ❤ by GitHub

Uma outra coisa a se notar é o seguinte: diferente de um loop for clássico, que acontece sequencialmente, este Parallel.For() pode ter sido escalonado para rodar em múltiplos processadores simultaneamente; portanto, um break não é imediato, evitando que a próxima iteração aconteça. Pelo contrário, é bem provável que um número de iterações tenham sido iniciadas em um breve momento anterior ao .Break() ser invocado. É por isso que, na linha 19, precisamos checar se podemos ou não continuar a iteração atual.

E finalmente, note que na linha 32 há um lock da variável random. Isso é necessário por se tratar de um processo paralelo, que vai potencialmente mutar essa variável concorrentemente ao longo das iterações. Idealmente, você vai evitar esse tipo de cenário, porque onde há lock, há contenção; e onde há contenção, há tempo despendido esperando. Você não quer isso, mas às vezes é preciso.

— A little break here —

Acho que esse é o momento ideal para dizer que paralelismo pode criar situações de concorrência, intencional ou acidentalmente, dependendo do que você está implementando — porque programação paralela é um tipo de multithreading; e multithreading é um tipo de concorrência.

Se você ficou com dúvidas sobre isso, se ficou confuso com a terminologia, se acha que é tudo a mesma coisa, ou algo assim, tudo bem, não se desespere. Eu sei que, como acontece com assincronicidade e paralelismo, concorrência e paralelismo também são confundidos o tempo todo.

O artigo da Wikipedia sobre concorrência (em inglês) traz um ótimo resumo do Rob Pike, que distingue bem uma coisa da outra: “Concurrency is the composition of independently executing computations, and concurrency is not parallelism: concurrency is about dealing with lots of things at once but parallelism is about doing lots of things at once. Concurrency is about structure, parallelism is about execution, concurrency provides a way to structure a solution to solve a problem that may (but not necessarily) be parallelizable.”

Se tiver um tempo extra, recomendo que você veja a apresentação do Rob Pike, Concurrency is not Parallelism, para ter uma introdução amigável ao assunto.

2. ForEach() ⎼ análogo ao foreach clássico

Semelhantemente ao que se pode fazer com um loop foreach, aqui, Parallel.ForEach() está recebendo um IEnumerable<int> provido por um generator (a.k.a. yield return). Além deste parâmetro, há ainda outros dois: um com opções de configuração de paralelismo e uma Action<int, ParallelLoopState, long>, que pode ser usada como no exemplo anterior.

O ponto de destaque neste exemplo vai para a linha 29, onde é definido um CancellationToken para o loop. Naturalmente, o objetivo do CancellationToken é sinalizar o cancelamento de um processo que está em curso. E neste caso, o cancelamento ocorre na linha 18, depois de um intervalo randômico ⎼ to spice it up.

private static void ParallelForEach()
{
var numberOfIterations = 1000;
var min = 0;
var max = 100_000_000;
// Will cancel the operation on a random time basis; it might complete sometimes
// depending on the host machine
var timeInMs = 0;
var cts = new CancellationTokenSource();
Task.Run(async () =>
{
timeInMs = new Random().Next(100, 1000);
await Task.Delay(timeInMs);
Console.WriteLine($"- Will cancel after {timeInMs} ms <———–");
cts.Cancel();
});
Console.WriteLine($"Generating random numbers from {min} to {max} over {numberOfIterations} iterations");
try
{
Parallel.ForEach(
GenerateRandomNumbers(numberOfIterations, min, max),
new ParallelOptions
{
CancellationToken = cts.Token,
//MaxDegreeOfParallelism = 100 // <– you can play with this number to get a grasp of how this works;
// // essentially, the max viable degree of parallelism is the number
// // of cores available; try a few numbers and watch it for time to
// // complete the work
},
(num, state, i) =>
{
Console.WriteLine($"- Iteration #{i} > Begin at thread #{Thread.CurrentThread.ManagedThreadId}, task #{Task.CurrentId}");
Console.WriteLine($"- Iteration #{i} > End: {num}");
});
}
catch (OperationCanceledException)
{
Console.WriteLine($"Parallel task was canceled by a CancellationToken after {timeInMs} ms");
}
}

Este é um pattern bastante comum na plataforma .NET, que você já deve estar bem acostumado, pois se vê isso por toda parte da biblioteca padrão.

3. Invoke() ⎼ neste caso, a coleção iterada é de algoritmos

Este caso é um pouco diferente dos dois anteriores. Nos casos anteriores, o problema que você estava querendo resolver era o seguinte: você tinha uma coleção de itens sobre os quais você queria aplicar um dado algoritmo, e para fazer isso o mais rápido possível, você queria tirar proveito do número de CPUs disponíveis e escalonar o trabalho entre elas.

Agora, neste caso, o problema é que você tem um número de algoritmos para executar (independentes uns dos outros, de preferência) e gostaria de fazer isso o mais rápido possível. A solução, no entanto, é essencialmente a mesma.

private static void ParallelInvoke()
{
var numberOfIterations = 1000;
var min = 0;
var max = 100_000_000;
Console.WriteLine($"Generating random numbers from {min} to {max} over {numberOfIterations} iterations");
var nums = GenerateRandomNumbers(numberOfIterations, min, max).Select(num => (long)num).ToArray();
// With Parallel LINQ it is a piece of cake — fast as f*k
var originalSum = nums.AsParallel().Sum();
Parallel.Invoke(
() => ExpensivePlusOne(ref nums, 0, nums.Length / 2),
() => ExpensiveMinusTwo(ref nums, nums.Length / 2, nums.Length)
);
var newSum = nums.AsParallel().Sum();
Console.WriteLine($"Sum of all random generated numbers are {originalSum} (original) and {newSum} (new) [{originalSum – newSum} less]");
}

Um bônus no código acima, que não tem exatamente a ver com a questão do .Invoke(), mas que é super interessante e vale a pena comentar, é o método .AsParallel() sendo invocado no long[], na linha 13. Este método é parte da chamada Parallel LINQ (PLINQ), que torna possível a paralelização de queries LINQ.

Como você já deve imaginar, o que a PLINQ faz é particionar a coleção em um número de segmentos, e então, executar a query em worker threads separadas, em paralelo, usando os processadores disponíveis.

A propósito, assim como os dois métodos anteriores, .Invoke() também suporta CancellationToken via ParallelOptions.

Processamento paralelo “dinâmico”

Chamamos essa categoria de dinâmica, porque não se trata de iterar em uma coleção e aplicar um determinado algoritmo; também não se trata de invocar uma lista de métodos em paralelo. Na verdade, trata-se de iniciar uma nova Task (ou mais de uma), que vai executar um processo custoso em uma worker thread, em paralelo, se possível, e poderá iniciar outras Tasks “filhas” a partir dela, criando uma hierarquia, onde a tarefa mãe só será concluída quando suas filhas tiverem concluído.

Inicia, se divide, trabalha, converge e finaliza.

Essa é a uma categoria de paralelismo em que, questões como: quantas tarefas mães, quantas tarefas filhas, que processos cada uma delas realiza, em que circunstâncias, em que ordem, etc, etc, etc, são todas respondidas em runtime, de acordo com as regras xpto de cada caso de uso. Daí referir-se a ela como dinâmica.

Os métodos da classe Parallel e a PLINQ são super amigáveis, convenientes, e você deve tentar usar sempre que possível. Mas quando o problema for um tanto mais flexível, dependente de informações conhecidas somente em runtime, o negócio é partir para Task. Por exemplo, você precisa percorrer uma estrutura de árvore e, dependendo do nó, executar um processo ou outro.

private static void ParallelParentAndChildren()
{
var numberOfIterations = 1000;
var min = 0;
var max = 100_000_000;
Console.WriteLine($"Generating random numbers from {min} to {max} over {numberOfIterations} iterations");
var nums = GenerateRandomNumbers(numberOfIterations, min, max).Select(num => (long)num).ToArray();
var task = Task.Factory.StartNew(
() => ProcessNumbers(nums), // <– let's pretend it is a so freaking CPU-intensive method
CancellationToken.None,
TaskCreationOptions.None,
TaskScheduler.Default
);
Console.WriteLine("Spawned parallel process of numbers and will wait until completion");
task.Wait();
Console.WriteLine("Parallel process of numbers has completed");
}
private static void ProcessNumbers(long[] nums)
{
var odds = new ConcurrentBag<long>();
var evens = new ConcurrentBag<long>();
Console.WriteLine("Will divide odds / evens in parallel");
Parallel.ForEach(nums, (num) =>
{
if (num % 2 != 0)
odds.Add(num);
else
evens.Add(num);
});
Task.Factory.StartNew(
() => ExpensiveWorkWithOdds(odds.ToArray()),
CancellationToken.None,
TaskCreationOptions.AttachedToParent, // <– pay attention at here
TaskScheduler.Default
);
Console.WriteLine("Spawned parallel expensive work on odds");
Task.Factory.StartNew(
() => ExpensiveWorkWithEvens(evens.ToArray()),
CancellationToken.None,
TaskCreationOptions.AttachedToParent, // <– pay attention at here
TaskScheduler.Default
);
Console.WriteLine("Spawned parallel expensive work on evens");
}

Note que o exemplo acima usa o método Task.Factory.StartNew(), para criar e disparar a execução de instâncias de Task, e não o famoso Task.Run(), que estamos bem acostumados a usar, quando estruturamos aplicações para lidar com problemas de modo concorrente.

O caso é que, o método .Run(), na real, nada mais é do que uma maneira conveniente de invocar.StartNew() com parâmetros “padrões” (veja o fronte dele aqui). E como você já deve imaginar a essa altura do campeonato, é justamente por conta desses parâmetros “padrões” que ele não atende aos requisitos do exemplo acima. Bem, para ser mais específico, estou me referindo ao parâmetro TaskCreationOptions.DenyChildAttach, que nos impede de ter tarefas filhas. Quer testar isso? Substitua .StartNew() por .Run() na linha 12, ou então, o parâmetro TaskCreationOptions.None por TaskCreationOptions.DenyChildAttach, na linha 15, e veja o que acontece ⎼ repare bem na ordem das saídas de console.

Viu?

A regra então é: sempre que for criar e disparar a execução de uma Task, priorize .Run(), a menos que o caso que você vai implementar peça por parâmetros específicos, diferentes dos padrões. Nestas circunstâncias, use .StartNew() e configure a gosto.

[Para ser mais correto com a informação que estou entregando, eu tenho que dizer que, nem o método .Run(), nem o método .StartNew(), “disparam” a execução de uma Task. O que eles fazem, na verdade, é “colocar a Task na fila de execução do ThreadPool, através de um TaskScheduler”. Veja que a própria classe Task tem uma propriedade Status que ajuda a entender seu o ciclo de vida.]

Dica #1 ⎼ Long Running Tasks

Podemos dizer que uma Task é long-running quando ela permanece rodando por minutos, horas, dias, etc. Ou seja, não é uma tarefa de alguns poucos segundos de duração. Para isso, há a opção TaskCreationOptions.LongRunning, que pode ser passada para o método .StartNew().

O que essa opção faz é sinalizar ao TaskScheduler que se trata de um caso de oversubscription, dando a chance do TaskScheduler se precaver contra um fenômeno conhecido como “thread starvation”. Para tanto, duas ações são possíveis ao TaskScheduler: (1) criar mais threads do que o número de CPUs disponíveis; (2) criar uma thread adicional dedicada à Task, para que assim, ela não engargale o fluxo natural de trabalho das worker threads do ThreadPool.

Até aí, tudo bem. Problema resolvido. Espere. Mesmo?

Tarefas com corpo async, ou seja, que seu delegate await por outras tarefas, por si só, naturalmente, já possuem um fluxo de execução escalonado de acordo com sua máquina de estado. Portanto, elas não consomem uma mesma thread por muito tempo; cada passo da máquina de estado pode ser executado em uma thread diferente.

Se você não sabe do que estou falando, veja a minha talk.

O que fazer então? A menos que seu processo long-running não use await, não aguarde a execução de nenhuma Task, seja lá de que maneira, esqueça a opção TaskCreationOptions.LongRunning. Use .Run() e deixe que o TaskScheduler faça seu trabalho autonomamente ⎼ em raros casos ele realmente precisa de “dicas”.

Dica #2 ⎼ Exceções

Sim, elas existem e uma hora ou outra vão ocorrer. Lide com isso. Quero dizer, literalmente!

Quando você estiver trabalhando com tarefas aninhadas, parent & children, como no exemplo que vimos há pouco, quando ocorrer uma exceção, você vai receber uma AggregateException borbulhando. Não importa se você await ou .Wait() a Task, uma AggregateException é o que você vai ter. Sendo assim, muito do que eu falo sobre o uso de .Wait() e exceções na minha talk não se aplicam a esse tópico de hoje. Mas isso não significa que você deva preferir .Await() em lugar de await ⎼ pelo contrário. Evite bloquear a “thread chamadora” desnecessariamente.

Uma dica para lidar com isso é usar o método .Flatten() da AggregateException. A grosso modo, o que esse método faz é criar uma única exceção com a exceção original incluída na propriedade InnerExceptions. Assim você não tem que ficar iterando por exceções aninhadas.

Outra dica é usar o método .Handle().

Dica #3 ⎼ Cuidado com aplicações web

Programação paralela visa aumentar o uso das CPUs, temporariamente, com intuito de aumentar o throughput da aplicação. Ótimo! Queremos isso. Afinal, muitos desktops ficam com a CPU idle boa parte do tempo. Mas cuidado, porque isso não é sempre verdade quando se trata de servidores.

Servidores rodando aplicações ASP.NET, por exemplo, podem ter menos tempo de CPU idle, dependendo do volume de requisições simultâneas, porque o próprio framework lida com múltiplas requisições em paralelo, visando tirar melhor proveito do ambiente multicore disponível. Portanto, botar código que atende a requisições de clientes para rodar em paralelo, pode acabar sendo um baita de um tiro no pé, quando houver um grande volume de requisições simultâneas.

Além disso, o ASP.NET pode limitar o número de threads usadas até mesmo pela TPL. Sendo assim, até mesmo um Task.Tactory.StartNew() da vida pode ser uma senhora martelada no dedão.

Via de regra, muito cuidado com multithreading em aplicações web.

Concluindo

Ao longo deste mais do que longo post, diferenciamos tarefas assíncronas de tarefas paralelas, nos aprofundamos nas paralelas e vimos algum código para ilustrar a discussão. Também vimos que concorrência não significa necessariamente paralelismo. Mas pode vir a ser, eventualmente.

https://github.com/leandrosilva/parallel-programming-article

Se você chegou até aqui e gostou, por favor, compartilhe com seus amigos, deixe seu comentário, suas dúvidas, e vamos nos falando.

Se mesmo tendo visto o meu webinar, você ainda tiver dúvidas sobre async/await, bota suas dúvidas aqui nos comentários. Quem sabe não consigo te ajudar? Não custa tentar.

Ah! E por falar em webinar, talvez eu faça um webinar discutindo o conteúdo deste post. Ou talvez me aprofunde mais no assunto concorrência, algo nessa linha. Seria a última peça do quebra-cabeças. Vou pensar a respeito.

Até a próxima!

BTW, estamos contratando 🙂

Se você ainda não conhece a nossa stack e quer saber como você poderia nos ajudar a construir a Pricefy, que foi recentemente adquirida pela Selbetti, dá uma checada nesses bate-papos:

  • ProdOps ⎼ Engenharia e Produto com Leandro Silva (link 1 e link 2);
  • ElvenWorks ⎼ Conhecendo a tecnologia por trás de uma solução muito inteligente de Precificação (link).

Se tiver interesse: leandro.silva@pricefy.com.br. A gente segue de lá.

Mentoria de Tech & Lead

Desde que me lembro, sempre fui autodidata. Muito do que aprendi, aprendi por iniciativa própria, movido por uma curiosidade que arde dentro de mim, lendo livros técnicos, artigos científicos (eu sou nutricionista, btw), blog posts, assistindo a vídeos e, não menos importante, observando atentamente pessoas que sabem mais do que eu sobre determinado assunto.

Ao longo dos anos, tive uns tantos mentores, formais ou informais, que me ensinaram muitas coisas e me sinto bastante afortunado por isso.

Continuando o cíclo

Ensinar sempre foi algo que me interessou bastante. Seja de modo formal ou informal, sempre tive um impulso natural de compartilhar com os outros aquilo que eu sei. O que sempre fez com que a roda girasse na minha vida, em um ciclo contínuo de aprendizado e compartilhamento.

E é por isso que estou iniciando um trabalho independente e gratuito de mentoria em tecnologia e liderança.

Público alvo

Jovens líderes, engenheiros de software, arquitetos de sistemas, etc. Qualquer pessoa que esteja a fim de aprender, crescer e dar o próximo passo em sua carreira, seja lá qual for.

Se você:

– está iniciando sua carreira em engenharia de software, está meio perdido com tanta sopa de letrinha, não sabe no que foca ou que caminho trilhar;

– está responsável pela arquitetura de sistemas da firma e está enfrentando um problema técnico que não sabe como resolver;

– está responsável pela liderança técnica de uma jovem startup e as incertezas estão te deixando apavorado;

– está a cargo de escalar o time de engenharia de produtos digitais e não faz ideia de por onde começar;

– está enfrentando problemas com seu sistema em produção dia sim, dia também, com bugs, gargalos, problemas de escalabilidade;

Essa mentoria pode ser para você. Pode ser que conversando, te ouvindo e dividindo com você um pouco da minha experiência, você encontre a resposta, a solução, o caminho, ou seja lá o que esteja buscando.

Essa mentoria não é para amigos, chegados. Vocês podem me chamar a hora que for preciso. É isso que significa ser chegado, né?

Como vai funcionar?

Vamos ter encontros de 1 hora, 1 vez por semana, durante 4 semanas. Estes encontros acontecerão via Google Meet, no melhor horário que atenda às nossas agendas.

A ideia é iniciar a mentoria na segunda semana de janeiro de 2022.

Aos interessados

Se você se interessou, se é justamente o que você está precisando, basta preencher o formulário abaixo:

https://forms.gle/zL8SoFMR1iKXGg229

Preencha o formulário e eu vou te retornar via e-mail, por onde seguiremos o papo.

A gente se fala!

Tecnologia, Produtos, Pricefy e afins

Andei batendo um papo muito bacana sobre a construção da Pricefy, engenharia de produtos digitais e a vida real em produção, com Christiano Milfont e Bruno Pereira, dois veteramos da tecnologia, que muita gente conhece desde os bons tempos de GUJ.

Para quem ainda não conhece o canal deles no YouTube, recomendo bastante dar um checada. Tem muita entrevista legal lá, com outros veteranos da área. Vale a pena conferir.

gRPC no Browser

Nos últimos 4 anos e pouco, a maior parte do código que lido no meu dia a dia profissional na Pricefy é C# .NET. É verdade que tem um tanto de JavaScript no front-end e Node.js em pontos específicos do back-end, mas a maioria esmagadora é .NET mesmo. Por isso não escrevo muito sobre .NET e procuro fazer meus projetos pessoais em outras tantas linguagens que também me interessam. Não porque não gosto de C#, mas porque não gosto só de C#.

Eu gosto de exercitar minha “mente de programador” em paradigmas diferentes e tentar caminhos alternativos para chegar ao mesmo lugar; às vezes mais rápidos, às vezes mais eficientes e outras vezes até mais elegantes. Com sorte, tudo isso junto.

Eu gosto disso.

Hoje, então, é um desses raros episódios em que C# aparece no blog.

O que é gRPC afinal?

Parafraseando longamente o próprio site oficial, gRPC é um framework de RPC open source, moderno, de alto desempenho, que pode ser executado em qualquer ambiente. Ele pode conectar serviços de forma eficiente dentro e entre data centers, com suporte plugável para balanceamento de carga, tracing, verificação de integridade e autenticação. Também é aplicável na última milha da computação distribuída para conectar dispositivos, aplicativos móveis e navegadores a serviços de back-end.

Inicialmente, foi desenvolvido pelo Google, lá pelos idos de 2015, juntando duas peças fundamentais já existentes: HTTP/2, para fazer o transporte, e Protocol Buffers, como IDL e método eficiente de serialização/deserialização de dados. Protobuf, para os mais chegados, também foi desenvolvido no Google.

Muitas empresas usam massivamente gRPC para otimizar a comunicação de microsserviços. Netflix e Spotify são algumas delas.

Também é muito empregado no segmento de Blockchain ⎼ eu mesmo, quando escrevi meu I.N.A.C.A.B.A.D.O. blockchain de brinquedo, há uns 3 anos, também lancei mão de gRPC em Go.

Complexidade de uso

No back-end e dispositivos móveis, seu uso é trivial. Você escreve um arquivo .proto que define o serviço (IDL), usa uma ferramenta para gerar código para uma determinada linguagem que vai implementar o servidor do serviço e para outras N linguagens que vão implementar o cliente do serviço. Voilà.

Concept Diagram
Fonte: Introduction to gRPC (link)

Já em navegadores a coisa não é tão simples assim.

Por se tratar de um protocolo que faz uso avançado de HTTP/2, seu uso em navegadores é mais “complicado” em servidores, para dizer o mínimo.

Basicamente, o que você precisa é ter um proxy na frente, fazendo traduções do cliente rodando no browser para o serviço no servidor. Não é o fim do mundo, mas com certeza passa longe do trivial. Mais sobre isso adiante.

Para tentar facilitar seu uso em navegadores, surgiu a iniciativa gRPC-Web.

O protocolo gRPC-Web

Sem muito rodeio, indo direto ao que importa, o que o protocolo gRPC-Web faz é prover uma API Javascript para navegadores baseada na mesma API do Node.js, mas por baixo dos panos, implementando um protocolo diferente do gRPC padrão, que facilita o trabalho do proxy na tradução entre o protocolo cliente e o protocolo servidor.

O proxy default dessa implementação é o Envoy. Mas há planos para que os próprios frameworks web das diversas linguagens implementem in-process proxy, eliminando a necessidade de um proxy terceiro.

Entra o ASP.NET Core

O que o time do ASP.NET Core fez foi exatamente isso: escreveram um middleware que faz o papel de proxy, eliminando a necessidade de se ter um proxy terceiro na jogada. Thanks, Sir Newtonsoft.

Seu uso ficou muito, muito prático. Uma vez que você tenha adicionado ao projeto referência ao pacote Grpc.AspNetCore.Web, basta duas linhas no Startup.cs:

Fonte: Configure gRPC-Web in ASP.NET Core (link | source)

E no cliente JavaScript, nada muito diferente do que já estamos acostumados a fazer em Node.js.

Fonte: gRPC for .NET / Examples / Browser (source)

Okay. Neste momento, se você lida com aplicações web requisitando serviços em servidores diferentes, provavelmente já pensou em quatro letras: CORS. O pessoal do ASP.NET Core também.

Fonte: gRPC-Web and CORS (link)

Será que vale a pena?

Difícil dizer se vale a pena ou não para seu projeto, para sua equipe, porque cada caso é um caso. Como quase tudo na vida, a resposta mais provável é DEPENDE.

Não é porque é novo e fácil, que é bom para tudo. Vamos pensar, então, em cenários de uso.

Um dos potenciais cenários de uso é quando você precisa lidar com grandes volumes de dados, trafegando pra lá e pra cá. Há algumas maneiras de se lidar com esse tipo de problema. Uma delas é usar um protocolo binário, pois o tamanho das mensagens é muito, muito menor, do que o de um protocolo texto, como JSON, e a serialização/deserialização é absurdamente mais rápida.

Size
Speed
Fonte: How to boost application performance by choosing the right serialization (link)

Pensando neste caso de uso e possível solução, a equipe do ASP.NET Core em parceria com a equipe do Blazor, escreveram uma aplicação web (Blazor WebAssembly) para demonstrar o uso do gRPC-Web vs o bom e velho JSON.

Abaixo, um printscreen que acabei de tirar, comparando uma requisição obtendo 10 mil registros em formato JSON vs 10 mil registros com gRPC-Web.

Fonte: Weather forecast – fetch data demo (link)

Conclusão

gRPC-Web traz para o ambiente de aplicações web muitas vantagens já consolidadas pelo gRPC tradicional, como por exemplo, o uso de mensagens binárias bem menores que o bom o velho JSON, serialização/deserialização consequentemente mais rápida, APIs de streaming e a adoção de contratos de API formalizados por IDL.

Para quem quiser saber mais sobre o assunto, recomendo What is gRPC? Protocol Buffers, Streaming, and Architecture Explained, Introduction to gRPC on .NET e gRPC services with ASP.NET Core para começar.

Cheers!

Tente ao menos ser elegante

Outro dia desses, pensando sobre software, arquitetura de sistemas, gente e a natureza falível de tudo isso, enquanto tomava a primeira xícara de café do dia, recém coado, me lembrei de um trecho do livro ZeroMQ, do Pieter Hintjens:

Most people who speak of “reliability” don’t really know what they mean by it. We can only define reliability in terms of failure. That is, if we can handle a certain set of well defined and understood failures, we are reliable with respect to those failures. No more, no less.

ZeroMQ: Messaging for Many Applications, Pieter Hintjens, page 141.

O que eu, na modéstia do meu intelecto, reduzi à frase:

Um sistema é tão confiável quanto foi preparado para ser.

À qual, eu certamente poderia acrescentar “nem mais, nem menos” ao final. Mas não tenho tanta certeza de que “nem menos” é tão verdade assim. Principalmente, em se tratando de sistemas distribuídos; ainda que se tenha um projeto super minucioso. Vou deixar com está.

Foi daí que saiu a motivação de fazer este post. Porque este é o tipo de assunto que fica subentendido em muitas conversas que temos em nosso dia a dia, projetando, implementando e mantendo software em produção. Parece que todo mundo sabe do que está falando, presume que a pessoa ao lado sabe do que está falando e que estão todos na mesma página, falando sobre a mesma coisa, partindo das mesma premissas e reconhecendo as mesmas limitações. Ainda mais em tempos de cloud, microsserviços, serverless e essa coisa toda.

Entretanto, embora isto deva ser bem óbvio, acho que devo pecar pelo excesso aqui e dizer que não tenho a pretensão de escrever um post que esgote o assunto “confiabilidade” de software e sistemas. Aliás, muito longe disto. A literatura sobre confiabilidade de software e sistemas é vasta e impossível de se cobrir em um post. Eu nem mesmo já consumi toda a literatura de que tenho ciência da existência. Portanto, não assuma que isto é tudo.

A ideia é ser um pequeno aperitivo, que desperte o seu apetite e te leve a buscar por mais.

Confiabilidade de Software

Para começar, então, vamos trazer um pouco de definição à mesa.

Michael R. Lyu, no livro Handbook of Software Reliability Engineering, define confiabilidade de software como sendo:

The probability of failure-free software operation for a specified period of time in a specified environment [ANSI91].

Michael R. Lyu, Handbook of Software Reliability Engineering, page 5. [pdf]

Ele expande ainda um pouco mais esta ideia, logo em seguida, explicando que a confiabilidade de software é um dos atributos da qualidade de software ⎼ que por si só é uma propriedade multidimensional, que inclui outros fatores também ligados à satisfação do cliente, como funcionalidade, usabilidade, performance, suportabilidade, capacidade, instalabilidade, manutenibilidade e documentação.

Martin Kleppmann, no livro Designing Data-Intensive Applications, falando sobre confiabilidade de sistemas de software, diz o seguinte:

The system should continue to work correctly (performing the correct function at the desired level of performance) even in the face of adversity (hardware or software faults, and even human error).

Martin Kleppmann, Designing Data-Intensive Applications, page 6.

Mais adiante, ele fala sobre o que tipicamente se espera de um software ⎼ que ele realize as funcionalidade esperadas pelo usuário; tolere erros cometidos do usuário; tenha desempenho bom o bastante para os casos de uso suportados, dentro do volume de dados esperado; e que previna acessos não autorizados e abusivos. Resumindo, o que ele diz é que o software deve continuar funcionando corretamente, mesmo quando as circunstâncias são adversas.

Enquanto Lyu apresenta 3 pontos importantíssimos para qualquer discussão sobre confiabilidade de software:

– Probabilidade de ausência de falhas;
– Por período específico;
– Em ambiente específico.

Kleppmann fala sobre continuidade de funcionamento correto dos sistemas mesmo quando as circunstâncias não são favoráveis a isto.

Aos sistemas que toleram tais situações adversas, ou falhas, damos o rótulo de “tolerantes à falhas”. Mas isto não significa que sejam tolerantes a todas e quaisquer falhas, porque isto seria absolutamente impossível. Há sempre uma restrição imposta por tempo para se projetar, implementar e testar uma solução, custo de viabilização, habilidade humana para realização e tamanho do retorno sobre todo investimento empregado. Em outras palavras, ter “tudo” nunca é uma opção.

Este é o ponto chave da menção que fiz de Hintjens no início deste post: um sistema tolerante à falhas é um sistema que está preparado para lidar com aquelas falhas que são bem conhecidas e compreendidas, em cujo qual certo esforço foi empregado para prepará-lo para tal. Nem mais, nem menos.

Agora, seja lá qual for a régua que adotemos, a verdade fria da coisa é que a confiabilidade de software é um fator crucial na satisfação do cliente, uma vez que falhas de software podem tornar um sistema completamente inoperante.

E não estou me referindo apenas a sistemas de ultra missão crítica, que podem causar a morte de pessoas, destruir vidas de verdade, como alguns exemplos que Pan oferece em seu artigo, mas de coisas mais mundanas mesmo, como Kleppmann cita em seu livro. Quero dizer, bugs em aplicações de negócio, que fazem seus usuários perder produtividade; indisponibilidades em e-commerces que impedem que vendas sejam concluídas; produtos de software que são vendidos, mas nunca provisionados no prazo prometido; e por aí vai. Você provavelmente deve ter ótimos exemplos muito melhores do que estes.

É claro que há momentos em que, conscientemente, decidimos sacrificar a confiabilidade de um sistema para reduzir seu custo inicial de desenvolvimento, porque estamos, por exemplo, desenvolvendo um protótipo ou MVP, que pode acabar indo para o lixo; ou pode ser o caso de termos que manter o custo total de um produto super baixo, porque sua margem de lucro não justifica nada além do mínimo possível. Ou simplesmente porque aquela funcionalidade incrível, que ninguém pode viver mais um dia sem, era para ontem.

Quem sabe se não é essa tal funcionalidade incrível, ainda que meio bugada, que vai fazer a diferença entre o sucesso ou a ruína de um produto?

Temos que estar realmente muito conscientes das decisões que tomamos, que afetam direta ou indiretamente a confiabilidade dos softwares que de algum modo comercializamos, já que este é um fator tão fundamental à satisfação dos nossos clientes.

Precisamos estar seguros em relação a pelo menos estes seis pontos:

– De onde estamos cortando;
– Porque estamos cortando;
– Por quanto tempo estamos cortando;
– Se é um corte remediável no futuro;
– O quanto o usuário vai sofrer com os cortes;
– E o quanto estamos ganhando com tudo isto.

Não se pode ter tudo. Mas pode-se ter consciência sobre o que não se tem e suas consequências imediatas e futuras.

A triste realidade de software

Diferente de bens materiais, como um carro, uma torneira, uma fotocópia, ou um equipamento eletroeletrônico qualquer, um software não envelhece, não enferruja, não se desgasta e perde funcionalidade com o tempo. Dado que todas as condições externas ao software, às quais ele depende, sejam exatamente as mesmas, não importa quantos anos de existência tenha uma determinada versão do software, ela vai funcionar tão bem (ou tão mal) quanto da primeira vez. Nhmmmm, sort of…

A verdade deprimente é que softwares que rodam continuamente por longos períodos de tempo, fazendo qualquer coisa minimamente importante para alguém, ainda que sob as mesmas [supostas] condições de ambiente nos quais estão inseridos (hardware, sistema operacional, rede, etc), com o tempo, vão se degradando, porque estas [supostas] condições externas vão se alterando e assim também suas próprias condições internas, ainda que seu código fonte ou mesmo binário permaneçam absolutamente inalterados.

Daí a importância de se ter um time de operação capacitado, munido com ferramentas de gestão de configuração, monitoria, telemetria e todo o mais possível.

Isto é, tanto quanto o conteúdo do código fonte mantém-se intacto quando este é submetido à execução, também a sua qualidade permanece a mesma. O que significa que, quanto menor a qualidade do código fonte, maior a probabilidade de seu estado em execução se degradar em um menor espaço de tempo e resultar em incidentes em produção ⎼ o que, em última análise, se traduz em cliente insatisfeito e imagem da organização arranhada.

E isto não é tudo. Em um ensaio sobre confiabilidade de software da Carnegie Mellon University, Jiantao Pan escreve que a complexidade de um software está inversamente relacionada à sua confiabilidade; quanto maior sua complexidade, menor sua confiabilidade. Já a relação entre complexidade e funcionalidade tende a ser causal; quando maior o número de funcionalidades, maior sua complexidade.

Lembra-se da máxima de que menos é mais? E daquela estimativa de que 80% dos usuários usam apenas 20% das funcionalidades? Pois é, chunchar funcionalidades para competir pode ser não apenas uma estratégia ineficaz, como também um ofensor da confiabilidade do sistema como um todo.

Diferentemente do que acontece com bens materiais, como disse há pouco, que vão se deteriorando e sofrendo aumento na ocorrência de falhas ao longo de sua vida útil, softwares tendem a ter maior ocorrência de falhas em sua fase inicial e muito menos em sua fase de obsolescência.

Fonte: https://www.beningo.com/understanding-embedded-system-failure-rates/

Note que o que causa aumento da chance de falhas, com efeito levemente residual, é justamente a implantação de novas versões; em especial, se elas trouxerem consigo novas funcionalidades. Não que esta informação deva nos deixar acanhados de publicar novas versões, absolutamente. O que devemos, sim, é não ignorá-la. Precisamos ser mais responsáveis com o que publicamos em produção, investindo tempo em testes melhores, detectando falhas mais rapidamente e oferecendo maneiras de revertê-las o quanto antes.

A bem da verdade, até mesmo novas capacidades que visam aumentar a confiabilidade podem causar novas falhas. Senão por falhas no código em si, resultado do novo ciclo (seja ele de um dia ou um mês) de projeto, implementação e teste, pelo simples fato do ambiente sistêmico de produção não ser exatamente o mesmo de desenvolvimento. Eles podem até ser muito, muito, semelhantes um ao outro, mas frequentemente não são o mesmo. Muitas vezes, o ambiente de produção de ontem nem mesmo é o de hoje. Quem dirá, o de uma semana atrás.

Dispositivos móveis são um ótimo exemplo disto. Você nunca tem certeza sobre o estado que vai estar o dispositivo móvel quando seu aplicativo for instalado. Isso sem falar em sistemas embarcados.

Outro exemplo é o caso de softwares que compõem um sistema distribuído. Uma nova versão de um software coincide com uma nova versão de outro software, que torna-os de algum modo conflitantes e incompatíveis. Pau!

O fator “pessoas”

Eu acho um baita clichê quando dizem que tudo é sobre pessoas; ou alguma variação disso, com o mesmo significado. Mas no final das contas, a verdade é que é isso mesmo.

As pessoas projetam expectativas. As pessoas fazem promessas. As pessoas não conseguem cumprir suas promessas. As pessoas sequer conseguem fazer as coisas direito 100% do tempo. Porque no final das contas, como dizem, isto é ser humano, não é? É, pois é.

Em seu ensaio sobre confiabilidade de software, Pan faz referência ao paper de Peter A. Keiller e Douglas R. Miller, intitulado On the use and the performance of software reliability growth models, e escreve que falhas de software podem ser decorrente de erros, ambiguidades, omissões ou má interpretações da especificação; podem ser também descuido ou incompetência na escrita do código, teste inadequado, uso incorreto ou inesperado do software; ou outros problemas imprevistos. Em resumo, pessoas sendo pessoas.

E a despeito dele ter escrito isto em 1999, mesmo com o posterior advento do desenvolvimento de software ágil, ainda não erradicamos os fatores causadores de “falhas de software”. Diminuímos, sim, mas não eliminamos. Talvez porque, como ele diz, eles estejam intimamente relacionados a fatores humanos confusos e ao processo de projeto da coisa que estamos construindo, os quais ainda não temos um entendimento sólido. Pessoas, right?

O guard-rail

Se você chegou até aqui, talvez esteja se perguntando: se não dá para erradicar completamente, tem que dar ao menos para controlar a magnitude, mitigar de alguma maneira, certo?

Sim, certo. Nem tudo está perdido. Já vimos algumas dicas ao longo do caminho.

Em muitos casos, com processos de desenvolvimento sólidos, com ciclos curtos de projeto, implementação e teste; com práticas de engenharia cínicas, que sempre esperam pelo pior; com engenheiros de qualidade que caçam pêlo em ovo; com processos de governança super defensivos; e com real atenção à operação desde a concepção do que se está construindo.

Em outros casos, sentando e chorando. Okay, este último não é exatamente uma opção.

Ciclos menores

Promover ciclos menores de “concepção à produção” é uma ótima prática para se adotar, se não por tudo que vimos até aqui (especialmente a questão dos pacotes novo em produção), porque as pessoas tem limiares de atenção diferentes. Mas não é só isso.

Muitas vezes, a velocidade do negócio e suas incertezas nos obrigam a mudar de foco antes de conseguirmos concluir uma determinada atividade e isto é super frustrante, mesmo que você tente entrar naquele estado mental de “estamos fazendo isso pelo negócio”, “este é o melhor a se fazer”, “estamos aqui pelo cliente”. Yeah, I get it. Mas ainda assim é frustrante. Como seres humanos nos sentimos frustrados de começar algo e não concluir, seja por desinteresse momentâneo ou por força maior. Então, o quanto antes concluirmos, melhor.

Então, quanto menores? Tão pequenos quanto o suficiente para se fazer um bom projeto, implementação limpa e testes de verdade. Em alguns casos, tudo isto pode acontecer em horas; em outros, pode levar semanas. O importante é que se saiba bem o que se está programando e como testar que tudo está saindo conforme o esperado.

Quebrar grandes requisitos em diversos requisitos menores, que vão se complementando, é um caminho para se ter ciclos menores. Com certeza, é um bom começo.

Transparência

Aqui me refiro à transparência do software rodando em produção, como ensina Michael T. Nygard, em Release It, na página 226.

Nygard diz que um sistema sem transparência não pode sobreviver por muito tempo em produção. Ele diz que se os administradores do sistema não sabem o que os componentes do sistema estão fazendo, eles não podem otimizar este sistema. Bem, seria muito perigoso mexer no que não se tem controle. E não se pode ter controle do que não se faz a menor ideia.

Se os desenvolvedores não sabem “o que funciona” e “o que não funciona” em produção, eles não podem aumentar a confiabilidade e resiliência do sistema ao longo do tempo.

E quanto aos sponsors de negócio? Se eles não sabem se eles estão ganhando dinheiro com o sistema, eles não podem investir em trabalho futuro de melhoria, etc.

Ele conclui, então, em tom meio apocalíptico, penso eu, dizendo que um sistema sem transparência vai seguir em decadência, funcionando um pouco pior a cada nova versão.

Segundo Nygard ⎼ e tendo a concordar fortemente, depois de anos e anos escrevendo software e mantendo-os em produção ⎼, sistemas que amadurecem bem são sistemas que oferecem algum grau de transparência.

elegÂnCIA

Vimos que não é possível um sistema ser tolerante a toda e qualquer falha possível e imaginável. Devemos priorizar de modo consciente quais falhas toleraremos, para mantermos o funcionamento correto do sistema para o usuário final, se possível, sem que sequer se perceba que houve algo de errado.

Reliability means making systems work correctly, even when faults occur. Faults can be in hardware (typically random and uncorrelated), software (bugs are typically systematic and hard to deal with), and humans (who inevitably make mistakes from time to time). Fault-tolerance techniques can hide certain types of faults from the end user.

Martin Kleppmann, Designing Data-Intensive Applications, page 22.

Mas haverá momentos, como já vimos, em que não será possível manter funcionalidades completas do sistema diante de certas falhas, seja por decisão ou impossibilidade técnica. Nestes momentos, precisaremos recorrer a outros artifícios, se não quisermos torturar nosso cliente com tanta insatisfação. Graceful Degradation é um desses artifícios, mas ele não vem “de graça”.

One approach to achieving software robustness is graceful degradation. Graceful degradation is the property that individual component failures reduce system functionality rather than cause a complete system failure. However, current practice for achieving graceful degradation requires a specific engineering effort enumerating every failure mode to be handled at design time, and devising a specific procedure for each failure [Herlihy91].

Charles P. Shelton and Philip Koopman, Developing a Software Architecture for Graceful Degradation in an Elevator Control System. [pdf]

A “degradação elegante” de um sistema, chamemos assim, é um atributo que às vezes é confundido com a tolerância à falhas, mas há diferença entre eles.

Enquanto a tolerância à falhas diz respeito a lidar com componentes falhos do sistema, fazendo algum tipo de substituição, alguma manobra, que não cause degradação da experiência do usuário e continue a prover as funcionalidades esperadas, a degradação elegante, é a última instância do gerenciamento eficaz de falhas, quando a falha em componentes do sistema chega a um ponto em que não é mais possível manter as funcionalidades completas, com desempenho adequado, mas apenas parte delas, visando sobretudo evitar falhas generalizadas.

The purpose of graceful degradation is to prevent catastrophic failure. Ideally, even the simultaneous loss of multiple components does not cause downtime in a system with this feature. In graceful degradation, the operating efficiency or speed declines gradually as an increasing number of components fail.

Graceful Degradation, TechTarget. [link]

Então, quando tudo for inevitavelmente para o despenhadeiro, porque o seu sistema chegou ao limite do quão confiável foi preparado para ser, tente ao menos ser elegante.

Rust, Node.js e a raiz de todos os males

Eu costumo usar Node.js com certa frequência, tanto no trabalho quanto em coisas pessoais. É um script aqui, uma API ali, uma CLI acolá. Em parte porque eu gosto de escrever JavaScript; gosto de linguagens de programação com chaves e ponto-e-vírgula. Mas também porque é super prático, tem lib para tudo que é coisa que você queira fazer e a performance é geralmente entre aceitável e boa. Quero dizer, se você colocar na balança a velocidade de desenvolvimento, as ferramentas disponíveis e o footprint vs. a performance de execução, para muitos casos, Node.js é bom o bastante.

Não é de hoje esse meu “estranho” gosto por JavaScript, {} e ;.

Mas não me entenda mal, Node.js tem certamente seus downsides. O principal deles talvez nem seja técnico, mas sim o que meu amigo Phillip Calçado apresentou na GOTO 2013, que está relacionado à ausência de boas práticas de design de software ou simplesmente o emprego das menos recomendáveis. Acredito que, em muitos casos, não por desleixo, mas por pura falta de conhecimento mesmo, porque há coisa de 10 anos, quando Node.js veio ao mundo e começamos a usar JavaScript no servidor, o emprego de boas práticas de programação no browser ainda era insignificante. Isso de alguma forma foi parar no servidor.

Conheça as ferramentas do seu ofício

Além de aprender boas práticas de engenharia de software e design de código em diferentes paradigmas de programação (estruturado, oo e funcional, por exemplo), é importante que se aprenda também um pouco mais sobre como funcionam as plataformas em si. Neste caso, Node.js. Com certeza isso fará diferença na hora de implementar aplicações de verdade e operá-las em produção sem [tanta] dor de cabeça.

Em se tratando de Node.js, a coisa mais importante a se entender é o seu Event Loop, porque é onde a magia acontece ou a coisa engripa. Se você tem uma base de ciência da computação, vai logo se lembrar que event loop não é um conceito novo que o Node.js inventou. É na verdade um design pattern bem mais antigo. E provavelmente, você vai perceber que há ainda um outro design pattern na jogada, o reactor ⎼ que muitas vezes é tratado como se fosse a mesma coisa, mas não é.

Fonte: Building and understanding reactive microservices using Eclipse Vert.x and distributed tracing.
Fonte: Node.js event loop architecture.

Se você quiser aprender mais sobre o Event Loop do Node.js em especial, recomendo muito esta ótima série de artigos, este aqui e este outro aqui também.

Reconheça suas limitações

Superada as limitações de conhecimento de programação, da linguagem e da plataforma, chegamos então às verdadeiras limitações da plataforma.

Se você chegou até aqui, provavelmente vai concordar comigo que é uma generalização dizer que Node.js é single-threaded. Mas o fato é que, na prática, para todos os efeitos, isto é verdade. Por isso, deve-se fazer todo possível para não bloquear o event loop, do contrário, a aplicação vai ficar enroscada.

Isto faz com que Node.js não seja um bom candidato para rotinas com processamento pesado, com muitos cálculos e algoritmos complexos, pelo simples fato de que isso leva tempo para executar e bloqueia o event loop, o que no final das contas, acaba limitando o throughput da aplicação.

Temos algumas soluções aqui. Uma delas é escrever essas rotinas em outra linguagem mais poderosa, compilada, com suporte a multithreading, etc e executá-las totalmente apartadas. Outra solução é trazer o poder dessas linguagens para dentro da nossa aplicação Node.js.

Worker Threads parecem interessantes também. Mas tenho zero experiência com elas.

Peça ajuda

C++ Addons tornam possível escrever funções super otimizadas, multi-threaded e tudo mais, que podem ser usadas em Node.js de modo totalmente natural, como se fossem funções comuns do JavaScript.

Addons are dynamically-linked shared objects written in C++. The require() function can load addons as ordinary Node.js modules. Addons provide an interface between JavaScript and C/C++ libraries.

The Documentation [link]

Agora, o fato é que escrever em C++ não é uma tarefa simples. C++ é uma linguagem extraordinariamente poderosa, mas não é fácil de se domar. Quero dizer, programar bem em C++ não é coisa que se aprende em um mês ou dois de vídeos no YouTube.

É agora que recorremos à analogia das facas? C++ é a faca do sushiman, enquanto que JavaScript é a faquinha de rocambole.

Portanto, trata-se de uma otimização com custo alto.

Rust entra no jogo

Rust também não é uma linguagem simples de aprender. Sua curva de aprendizagem é íngreme. Bem íngrime. No entanto, ela é mais segura para quem está aprendendo do que C++, com toda certeza.

Com Rust é possível escrever código com performance compatível com C++, porém com memory safety e fearless concurrency, para usar os jargões da linguagem. O que, neste caso, tornaria o custo de otimização de um programa Node.js que chegou ao seu limite mais acessível.

Será que isto é possível? A resposta é sim. É possível escrever extensões nativas para Node.js em Rust já faz bastante tempo. Mas eu, só há umas duas semanas me dei conta disto e acabei descobrindo uma ferramenta que torna isto muito, muito, fácil mesmo.

O que Neon faz é oferecer um conjunto de ferramentas e bindings para facilitar escrever código em Rust, gerar uma biblioteca nativa e usar em Node.js como se fosse uma função JavaScript qualquer, exatamente como seria com C ou C++.

Diferente de algumas soluções em que se usa apenas FFI para fazer chamadas a bibliotecas nativas, que obviamente poderiam ser escritas em qualquer linguagem, Neon faz bind direto na API da V8, para interagir com JavaScript. O que para eles é um problema, porque quando a API da V8 muda, eles precisam mudar também. Por isso há uma iniciativa de implementar o bind via N-API.

Anatomia de uma extensão Neon

Para ter uma ideia de como é escrever uma extensão em Rust com a ajuda de Neon, fiz um projeto de teste, um contador de palavras básico. O processo foi muito simples e sem enroscos. Fiquei realmente surpreso.

Uma das coisas que me agradou bastante é que a ferramenta cria uma estrutura de projeto padronizada, bem organizada, onde você tem um diretório para a biblioteca Node.js e um para a biblioteca nativa em Rust.

Fonte: https://github.com/leandrosilva/verbum-counter

No diretório lib, você tem um arquivo index.js, que é o ponto de entrada da biblioteca, e que faz nada mais nada menos do que importar e exportar a biblioteca nativa em Rust.

Fonte: https://github.com/leandrosilva/verbum-counter/blob/master/lib/index.js

Já no diretório native, você tem um diretório padrão de projetos Cargo.

Fonte: https://github.com/leandrosilva/verbum-counter/tree/master/native

Em src, você tem um arquivo lib.rs onde a magia acontece.

Fonte: https://github.com/leandrosilva/verbum-counter/blob/master/native/src/lib.rs

Okay. Neste caso, a magia não é tão encantadora assim.

O que acontece, aqui, é que eu exporto uma função que cria um tarefa de contagem de palavras, agenda ela para executar num futuro próximo e passa adiante o callback que recebeu do código JavaScript.

Esta função, depois, é usada no código JavaScript sem que fique aparente que ela é uma função externa, implementada em Rust.

Versão em puro JavaScript vs. com Addon em Rust.

Cuidado com a otimização prematura

Foi uma experiência bem legal escrever essa extensão e provavelmente devo escrever mais no futuro. Mas a lição mais importante que gostaria de deixar, aqui, neste post, é que você deveria procurar aprender cada vez mais sobre a sua profissão e suas ferramentas de trabalho, tanto as que usa hoje, quanto as que poderá usar amanhã ou depois, antes de qualquer outra coisa.

Em outras palavras, não é porque é possível e relativamente fácil escrever extensões nativas em Rust e usar transparentemente em Node.js, que você vai sair correndo reescrevendo tudo para fazer seu programa voar. Antes de partir para uma abordagem destas é preciso ter certeza de que você empregou o melhor design possível e esgotou todos os recursos “normais” da plataforma. Porque trazer para o jogo uma nova linguagem não é algo gratuito, muito embora pareça ser ⎼ e traz também consigo novos problemas.

Por exemplo, no caso deste contador de palavras, a versão que usa a biblioteca nativa em Rust (que inclusive usa Rayon para paralelismo) performa pior do que a versão em JavaScript puro, quando o arquivo texto não é grande o bastante. Isto porque a mudança de contexto entre JavaScript e Rust tem seu custo.

Há um limiar a partir do qual uma otimização mais hardcore é realmente necessária. Até que se ultrapasse esse limiar, o melhor que se tem a fazer é fazer o melhor com o que se tem nas mãos.

Como disse Donald Knuth: “A otimização prematura é a raiz de todos os males”.

Monolitos, microsserviços e os guardiões de dados

No meu post anterior, falei sobre dados e arquiteturas de sistemas, enfatizando a importância de se cuidar dos dados de uma organização desde sua forma mais fundamental, no momento de seu primeiro registro histórico, ainda em estado bruto, de modo a favorecer a evolução e incremento de seu uso nos negócios atuais da organização e do futuro que está além do horizonte, uma vez que dados tendem a ter longevidade maior do que aplicações.

We observed that database systems are characterized by longevity of data. They usually involve a large volume of data that can last for a considerable period of time. Furthermore, the longevity of data and its impact in long term made it a valuable asset that had to be well managed. Given the longevity of data it is difficult to foresee all its possible uses, and hence it is impracticable to define a specific interface that would satisfy all applications developed during its lifetime.

Jack Campin, Daniel Chan, and David Harper, Specifications of Database Systems, page 316.

O ponto chave aqui é que, ao registrar os dados que entram e que nascem em uma organização, deve-se levar em conta não apenas o uso que se faz deles hoje, mas que se poderá fazer no futuro. Muitos produtos e oportunidades de negócios já nasceram a partir de dados que foram coletados e armazenados com uma finalidade, mas que em um outro momento, mostraram-se úteis para muito mais do que se pensou.

Portanto, cuide dos dados de sua organização tão bem quanto possível.

Se não pelas possíveis oportunidades de negócios futuras, pela própria evolução dos sistemas que o sucesso do seu negócio atual deve cobrar, cedo ou tarde.

O nascimento de um sistema

Fazer um startup com um sistema monolítico (a.k.a. monolito) é provavelmente a abordagem mais sensata, com raras exceções. Uma delas seria o caso do mercado de APIs, porque a própria natureza do negócio tende a favorecer certa granularidade.

O problema de se começar um projeto orientado a serviços (a.k.a. microsserviços, no dias atuais) é o investimento inicial que se precisa fazer em análise e design, para se definir contextos, fronteiras, correndo o risco de se incorrer no erro de big design up front, granularidade exagerada dos componentes de sistema e terminar com uma arquitetura mais complexa do que o necessário para suportar o momento atual do negócio. Pior ainda, pode-se terminar com uma arquitetura mais complexa do que se possa manter com a equipe atual. Incidentes à vista!

So my primary guideline would be don’t even consider microservices unless you have a system that’s too complex to manage as a monolith. The majority of software systems should be built as a single monolithic application. Do pay attention to good modularity within that monolith, but don’t try to separate it into separate services.

Martin Fowler, Microservice Premium [link]

Especialmente para startups, além de favorecer a simplicidade de design e implementação, começar um projeto como monolito trás a vantagem de abreviar o tempo que se tem entre a ideia e o lançamento do produto; e depois, o aprendizado que se tem ao longo de seu amadurecimento. À medida que o negócio vai escalando, a demanda do sistema vai aumentando, pedindo não somente por disponibilidade, performance, escalabilidade, resiliência, devido ao seu sucesso, mas também por velocidade de implementação de novas funcionalidades para o usuário final, com qualidade e sem afetar o que já existe.

Este aprendizado e experiência com o sistema em produção é fundamental para o amadurecimento do time (não somente como indivíduos, mas como grupo) e o sucesso de uma empreitada de microsserviços.

Fonte: https://martinfowler.com/bliki/MonolithFirst.html

Porque o time que trabalhava junto, com todas as mãos na mesma massa, agora, por força do aumento de demanda do negócio e complexidade do sistema que se formou, vai ter que se dividir para conquistar, se quiser recuperar a produtividade.

Fonte: https://martinfowler.com/bliki/MicroservicePremium.html

Cada pequeno grupo, agora, vai ter que assumir a responsabilidade por uma parte do todo; vai ter que caminhar de forma autônoma, com desafios e objetivos próprios, porém interagindo e contribuindo com os demais grupos por um bem comum ⎼ que em última análise é a missão da organização.

Voltamos então à questão dos dados.

Evolução e escala dependem de dados

Quando os times começam a se dividir e, com isto, dividir também o monolito que os trouxe até o ponto de sucesso onde estão, um dos primeiros problemas com que precisam lidar diz respeito aos dados.

Dentre as principais motivações para se implementar uma arquitetura baseada em microsserviços é que cada time tenha autonomia para evoluir seus microsserviços e implantá-los em produção sem depender rigidamente de outros times. Idealmente, cada microsserviço deve estar fracamente acoplado a outros microsserviços. Isto não é possível no padrão tradicionalmente adotado por monolitos, onde um único banco de dados está no centro da arquitetura, porque qualquer mudança feita no schema deste, para atender a um determinado microsserviço, pode impactar diretamente outros tantos microsserviços.

Acredite em mim quando digo que não é preciso ser muito azarado para causar um problema deste tipo em produção. Anos e anos de integração de aplicações via banco de dados já provaram isso.

Mesmo que mudanças no schema do banco de dados não sejam um problema para sua organização, porque ela tem um processo de governança super rígido e centralizado, que possibilite controlar a menor mudança que seja no banco de dados e supostamente garantir que não ocorra incidentes decorrentes delas, ainda assim, haveria o problema de escalabilidade, disponibilidade e performance.

Afinal de contas, você não vai querer que o uso que um microsserviço faz do banco de dados penalize outros microsserviços. Ou que uma falha no banco de dados afete todos os microsserviços que estão conectados a ele. Isto não seria muito melhor que um monolito. Seria ok para um primeiro passo, mas aconselhável dar o próximo.

Dividir uma instância de banco de dados não é por si só um problema, desde que se selecione os vizinhos com cuidado, para não promover o chamado noisy neighbor effect. Na verdade, pode ser uma estratégia.

É preciso então considerar alternativas, caminhos que progressivamente viabilizem o desacoplamento.

Modelos de arquitetura de sistemas

No post anterior, vimos um modelo de arquitetura de sistemas centrado em um fluxo de dados imutáveis, que são perpetuamente registrados (como fatos históricos) e constantemente propagados para quem possa interessar. Na prática, um log.

Como escrevi lá e repito aqui: log stream é apenas um dos modelos possíveis. Há muitos modelos de arquitetura documentados, que dão conta de dados e integração de sistemas, como 123 e 4, por exemplo. É importante conhecer pelos menos alguns e ter senso crítico na hora da adoção.

Portanto, hoje, quero apresentar um outro modelo, um padrão que pode ser implementado sozinho ou combinado com log stream.

Dados pedem um guardião

Se você já foi exposto a sistemas distribuídos por algum tempo, certamente já se deu conta do quão verdade é a primeira lei da distribuição de objetos.

First Law of Distributed Object Design: Don’t distribute your objects!

Martin Fowler, Patterns of Enterprise Application Architecture [link].

O mesmo vale para dados em sua forma mais primária; especialmente quando podem ser modificados por mais de uma via. Basta permitir que múltiplas aplicações modifiquem a mesma fonte de dados (uma tabela de RDBMS, por exemplo) e o inferno está instaurado na terra. Com direito a limbo e tudo mais. Digo, cache.

Portanto, o princípio fundamental deste modelo apresentado aqui, conhecido como Database per Service, é que um dado deve pertencer a um e somente um microsserviço; e somente ele deve ter poder para modificá-lo.

Neste modelo, todo acesso a uma fonte de dados é rigidamente guardado por um microsserviço, que deve oferecer uma API de acesso aos interessados. Este padrão de isolamento também é conhecido como Application Database.

Obviamente, como você pode imaginar, há vantagens e desvantagens neste modelo. Vamos ver algumas.

Vantagens

– O acesso direto ao banco de dados é feito por uma única via;
– A consistência dos dados é feita em um ponto único;
– Pode-se modificar o schema do banco sem afetar outros microsserviços;
– É possível escolher o banco mais especializado para a estrutura de dados;
– Maior agilidade da ideia à produção.

Desvantagens

– Mais complexo de gerenciar, pois tem mais componentes;
– Transações precisam ser coordenadas entre múltiplos microsserviços;
– Consultas precisam agregar múltiplas origens de dados de microsserviços;
– Modificações nos dados precisam ser notificadas aos interessados;
– Performance de operações simples podem ser impactadas.

Como você pode ver, são ótimas vantagens, é verdade. Mas as desvantagens não são poucas. No entanto, o ponto chave aqui é que a complexidade é inerente ao problema em mãos. Frente à natureza do problema, qualquer solução que se proponha vai ser complexa de implementar e trazerá consigo desafios.

Que tal pensar em como superar os desafios decorrentes?

Possíveis soluções para os problemas apontados

1. Mais difícil de gerenciar

É nesta hora que automação de processos de operação, infraestrutura como código, serverless e práticas de devops vem bem a calhar.

2. Transações precisam ser coordenadas entre múltiplos microsserviços

Este é um dos maiores desafios de uma arquitetura com database per service. Não é um desafio novo, no entanto. Transações distribuídas tem sido um problema há muitos anos. Desde que os dados precisaram ser distribuídos, provavelmente.

Uma maneira de lidar com esse problema é usando o padrão Saga.

A saga is a sequence of local transactions. Each local transaction updates the database and publishes a message or event to trigger the next local transaction in the saga. If a local transaction fails because it violates a business rule then the saga executes a series of compensating transactions that undo the changes that were made by the preceding local transactions.

Chris Richardson, Saga Pattern [link]

É bom lembrar que, para que isto seja possível, é preciso aceitar a possibilidade de consistência eventual. Temos a tendência de pensar em ACID o tempo todo, mas a verdade é que nem todos os processos de negócio precisam ser assim.

3. Consultas precisam agregar múltiplas origens de dados de microsserviços

Uma das soluções possíveis para este desafio é usar o padrão conhecido como Back-end for Front-end (BFF), que resolve o problema de agregação de dados, criando uma fachada especializada para cada aplicação cliente.

Este padrão resolve não somente o problema prático de se fazer agregação de dados, para promover uma experiência rica nas aplicações clientes, mas principalmente, dois problemas diretamente derivados dele no contexto de sistemas distribuídos: verbosidade e latência de rede que se tem ao se fazer múltiplas requisições a microsserviços para compor um conjunto de dados para uma aplicação cliente.

Fonte: https://philcalcado.com/2015/09/18/the_back_end_for_front_end_pattern_bff.html

Uma outra alternativa bem conhecida é o padrão Command Query Responsibility Segregation (CQRS). A abordagem aqui é criar views read-only, cuidadosamente modeladas (a.k.a. desnormalizadas) para atender ao domínio de negócio local a um microsserviço.

Geralmente, para manter os dados destas views atualizados, se faz uso de eventos, que são publicados pelos microsserviços ao modificarem os dados que guardam ou por mecanismos de change data capture.

4. Modificações nos dados precisam ser notificadas aos interessados

Quando se incorpora os princípios de uma arquitetura orientada a eventos, este problema meio que é a regra do jogo, então não há muito o que dizer a respeito.

O item anterior toca brevemente neste ponto.

Obviamente, não precisa haver uma arquitetura totalmente orientada a eventos para se beneficiar da troca de mensagens, pub/sub & Cia. Basta ter um middleware simples de mensageria e vòila. Sempre que um microsserviço modificar um dado na base, ele publica a novidade em um tópico. Quem tiver interesse no assunto, se inscreve no tópico e faz algo a respeito.

5. Performance de operações simples podem ser impactadas

Sim, fazer um join bem feito entre duas, três, quatro tabelas em um banco de dados relacional, com índices bem construídos, é bem performático na maioria dos casos. É difícil competir com isto. Mas colocando de lado a complexidade de implementação do padrão CQRS, consultas em tabelas desnormalizadas tendem a ser bem mais rápidas.

Tudo a seu tempo

É difícil pensar em uma arquitetura de microsserviços em que o banco de dados seja livremente compartilhado por todos. Especialmente depois de tudo que vimos até aqui. Mas há algumas estratégias para se compartilhar o banco de dados e ainda assim promover algum desacoplamento entre os serviços.

Eu gosto de pensar nestas estratégias como as marchas de um carro, que você vai engatando conforme vai desenvolvendo velocidade. Você até pode pular uma marcha ou outra, mas não adianta sair da primeira direto para a quarta ou quinta. É preciso estar a certa velocidade para tirar proveito de cada uma delas.

Analogamente, eu penso que você pode usar essas estratégias enquanto caminha de um extremo ao outro, de uma arquitetura monolítica para uma com um banco de dados por microsserviço, conforme a velocidade de crescimento da sua organização, modelo de negócios, demanda imposta aos sistemas e habilidades atuais do time.

Mesmo schema, diferentes usuários

Nesta abordagem, cada microsserviço tem seu usuário em um único servidor de banco de dados e compartilha o schema com os demais. Privilégios de leitura e escrita são controlados por usuário.

Evolução dos schemas ainda são um problema e requer um bom trabalho de governança para mantê-lo, bem como os privilégios dos microsserviços. Mas pode ser um caminho para começar a criar uma barreira que delimita quem tem a guarda do que.

Múltiplos schemas, mesmo servidor de banco de dados

Aqui, cada microsserviço tem seu próprio schema, realiza transações em seu próprio schema e participa de sagas. Muito embora não seja o ideal, enquanto o time não tiver condições para implementar Saga, as transações podem ser estendidas aos schemas dos vizinhos.

Nesta abordagem, quando um microsserviço precisa de dados de outro, ele os obtém a partir da API do microsserviço que tem a guarda dos dados desejados. Novamente, embora não seja o ideal, em um primeiro momento, pode-se até fazer um UNION ALL com os schemas dos outros.

É um nível de desacoplamento bem interessante, que pode te levar longe até que a próxima estratégia se faça necessária. Com os microsserviços restritos ao seus próprios schemas, sendo monitorados individualmente, torna-se relativamente fácil migrá-los para instâncias dedicadas, havendo justificativa para isso.

Arrematando

Monolito não é xingamento. É geralmente o melhor começo.

Arquitetura de microsserviços pode estar em voga e todo mundo estar comentando, mas não é fácil de implementar e nem é a solução para todos. Na verdade, ela trás consigo seus próprios desafios. Portanto, deve ser considerada somente quando o estágio do negócio e a demanda que este impõe em seus sistemas de software estejam gritando por isto.

– A organização está em sucesso crescente;
– O time está crescendo para atender a demanda;
– O monolito está super complexo, difícil de manter e evoluir;
– A produtividade está em declínio;
– Boas práticas de automação e devops estão ficando inviáveis;
– Escalabilidade, disponibilidade e performance estão em cheque.

Quando esta hora chegar, lembre-se de seus dados. Ou os incidentes te lembrarão de que você se esqueceu deles.

Idealmente, é bom que cada domínio de dados tenha seu próprio microsserviço guardião e que só ele tenha poder de modificá-los.

Dados, necessidades básicas e arquitetura de sistemas

Dados estão por toda parte. Eles sempre estiveram. Cada dia em maior número e mais diversos do que costumamos pensar.

Dados são a origem fundamental a partir da qual toda informação existe.

Any time scientists disagree, it’s because we have insufficient data. Then we can agree on what kind of data to get; we get the data; and the data solves the problem. Either I’m right, or you’re right, or we’re both wrong. And we move on.

Neil deGrasse Tyson

Dados são fatos em estado bruto, não organizados, que precisam ser processados. Por vezes, podem parecer aleatórios, mas quando processados, organizados, analisados e apresentados em um determinado contexto, nos dão informações úteis para o conhecimento factual do mundo ao nosso redor.

Evidence based medicine (EBM) is the conscientious, explicit, judicious and reasonable use of modern, best evidence in making decisions about the care of individual patients. EBM integrates clinical experience and patient values with the best available research information.

Izet Masic, Milan Miokovic, and Belma Muhamedagic [article]

Sem conhecimento factual não há como ter evidências de que algo é real ou irreal, verdadeiro ou falso, promissor ou um completo desperdício de tempo e dinheiro. Evidências são a razão para acreditar.

Primeiro as primeiras coisas

“Big data, AI, machine learning, deep learning, data-driven”, não sei se em outras áreas é assim, mas em desenvolvimento de software adoramos a nova palavra da moda. Não importa se o objeto ao qual a palavra se refere é algo novo ou não, o que importa é que se “todo mundo” está falando, vamos falar também.

No final das contas, a coisa acaba ficando mais ou menos como a famosa frase do professor de psicologia e economia comportamental Dan Ariely:

Big Data is like teenage sex: everyone talks about it, nobody really knows how to do it, everyone thinks everyone else is doing it, so everyone claims they are doing it.

Dan Ariely, Duke University [tweet]

O que é menos conhecido é um outro comentário dele, desta vez sobre educação e aprender a lidar com dados:

Instead I proposed that management education and practice should become much more experimental and data-driven in nature — and I can tell you that it is amazing to realize how little business know and understand how to create and run experiments or even how to look at their own data! We should teach the students, as well as executives, how to conduct experiments, how to examine data, and how to use these tools to make better decisions.

Dan Ariely, Duke University [post]

Educação antes de chavão. Aprender a lidar com dados para então poder ser guiado por eles. A menos que você não se importe de se deparar com a beira do penhasco tarde de mais.

Começando por baixo

Tomando emprestado um pouco da teoria da Hierarquia de Necessidades de Maslow e extrapolando para o nosso assunto dados, dá para pensar em uma pirâmide mais ou menos como a seguir:

E enquanto vemos que muito se fala de inteligência artificial, machine learning, deep learning, etc, pouco ainda se discute sobre o fundamental, sobre a base da pirâmite: coletar dados de maneira consistente, confiável e que permita escalar a pirâmide com segurança. Ou pelo menos, com mais segurança do que nenhuma segurança.

Já ouvi inumeros casos de empresas que contrataram cientista de dados quando nem sequer tinham dados para serem trabalhados. Melhor dizendo, não é que não tinham dados, porque como vimos no começo deste post, dados estão por toda parte; o que eles não tinham era coletado seus dados de maneira sistemática e disponibilizado para que pudessem ser usados para analise, modelagem, predição e automação de processos.

Dados precisam fluir

Quando se pensa em coleta de dados, logo se pensa em integração de dados.

O problema de integração de dados existe há décadas, provavelmente, desde que dados armazenados e processados em um certo mainframe precisou ser transportado, armazenado e processado em outro mainframe, para servir de consolidação ou qualquer outro propósito. Bem, certamente, muito antes dos mainframes existirem, se formos considerar o modo como os censos demográficos dos países eram realizados há séculos. Mas vamos nos ater aos computadores modernos.

Denunciando um pouco a minha idade, eu entrei para o saudoso colegial técnico em processamento de dados em 1997, quando tínhamos uma matéria que se propunha a nos ensinar Técnicas de Sistemas de Processamento de Dados. Na época, usavamos DBase III+ e Clipper.

De lá para cá, vi sistemas serem integrados por arquivo texto transferidos por disquete ou modem; depois foram os drives compartilhados na rede local e o FTP para quem estava de fora.

Então, depois de um tempo, começamos a fazer integrações em banco de dados – Interbase, Oracle, DB2, Sybase. Todo mundo se conectava ao gerenciador de banco de dados que estava disponível na rede e era aquela festa. “Deadlock? Yeah, true story, bro.”

Aí foi a vez do SOA com seus imponentes ESBs. Bem, imponentes até a página dois, quando a festa da integração via banco de dados migrou para eles.

A moral da história qual é? Dados precisam fluir. Eles precisam ir de um lugar para o outro; e de uma maneira ou de outra, isso vai acontecer.

Deixando os dados fluírem

Se você chegou até aqui, você deve estar se perguntando: ok, deve haver uma maneira certa de fazer os dados fluírem, certo? A resposta é depende.

– Para aonde esses dados precisam ir?
– Com que frequência?
– Qual o seu volume?
– Eles são sensíveis? (LGPD)
– O que acontece se eles forem corrompidos?

Estas e outras perguntas é que vão nos guiar à maneira mais correta de implementar o fluxo dos dados que entrarão, circularão e sairão da organização em que trabalhamos. Arrisco a dizer que não há certo e errado, mas sim, a maneira que dará mais ou menos dor de cabeça no curto, médio e longo prazo.

Acrescente à conta:

– Custo de implementação da solução inicial;
– Custo de manuteção da solução em produção;
– Custo de migração para uma possível nova solução;
– Expertise da equipe que vai fazer tudo isso.

Uma dentre muitas

Muito embora, possa não existir “a maneira certa” ou “a maneira errada” em absoluto, há muitas maneiras que já foram documentadas, aqui, ali, e acolá; e há certamente bastante terreno comum entre elas.

O que eu quero, então, é dedicar o final deste looongo post para falar brevemente, em alto nível, sobre uma maneira que há alguns anos tem emergido com sucesso e sido recomendada para muitos casos de plataforma de dados.

Eu acho que a primeira coisa que se pode notar no diagrama acima é que trata-se de uma arquitetura de sistemas centrada em um log stream.

Arquiteturas desta natureza tem o log stream como canal de entrada de dados para um contexto onde possam ser conhecidos, usados e manipulados por diversos sistemas e aplicações de um ecossistema.

Uma característica fundamental de logs é que são append only por natureza. Já a durabilidade de seus registros pode variar de acordo com a finalidade deles, podendo ser finita ou indeterminada.

Algumas empresas vão tão longe quanto terem seu log stream como fonte única de verdade para seu ecossistema de aplicações. Na prática, um registro histórico. Ou seja, em caso de perda de dados por falha humana ou bug em alguma aplicação, que tornou seu estado persistente inválido, é possível usar o log para voltar no tempo, reprocessar seus registros factuais cronologicamente e chegar a um novo estado persistente, agora correto.

Viagem no tempo, eles disseram.

Desnecessário mencionar que, em arquiteturas orientadas a eventos, o log stream é a espinha dorsal de onde os eventos são propagados.

Okay, vamos segmentar um pouco o diagrama acima.

A Entrada

A ideia é que este canal permida a entrada tanto de dados brutos quanto limpos. Podendo os últimos serem fatos e eventos do processo de negócio, tais como perfil de usuário criado, pedido de compra realizado, pagamento confirmado, etc. Já os primeiros, dizem mais respeito a logs de acessos, telemetria, clickstream, tudo o que precise ser analisado dentro de um contexto, para então produzir alguma possível informação útil para o negócio em um futuro não imediato.

Protocolos, schemas, metadados, são todos importantes desde este momento para que o fluxo dos dados seja confiável e sem entraves ao longo do caminho; principalmente, porque o tempo de vida dos dados pode ser indeterminado. O que quer dizer que você pode ter que lidar com um registro malformado por muito, muito, muito tempo mesmo. Mas isto não quer dizer que não deva haver flexibilidade. Pelo contrário, significa que deve haver intenção no formato dos dados, seja ele bruto ou tratado, e metadados que os descrevam. Isto vai promover descoberta e uso por um longo tempo.

Uma coisa interessante sobre dados é que eles geralmente vivem mais tempo do que as aplicações pelo meio das quais foram registrados pela primera vez.

Dados brutos, crus, podem ser trasnformados em algo útil para uma determinada aplicação (a.k.a. event sourcing) e/ou serem enviados para um data lake, para depois então servir de input para analises diversas ao escalar a pirâmide que vimos há pouco.

Dados limpos, por sua vez, geralmente vão ser eventos que precisam ser respondidos por algum agente primário e podem ter muitos outros espectadores secundários, outros agentes interessados em fazer algo com o fato noticiado. Por exemplo, a notícia de que um pedido acabou de ser finalizado por um determinado cliente de e-commerce. Quantos processos podem ser iniciados a partir deste fato? Desde processos transacionais, para que o produto seja finalmente entregue, quanto processos analítcos, para entender a venda realizada, colocando tudo no papel, a lista poderia ficar tão longa quanto este post.

O Processamento

O que acontece nessa etapa? Quem é que sabe! Isso vai depender da organização.

Em geral, o que vai acontecer são limpezas, transformações, reestruturações, enriquecimentos de dados, para então serem carregados em outras bases de dados com finalidades específicas, sejam elas relacionais, não-relacionais, analíticas, fulltext search, ou mesmo sistemas ERP, CRM e o que quer que se possa imaginar.

Como mencionado no estágio anterior, processos de negócio podem ter seu início aqui também. Aliás, sendo uma arquitetura centrada em log stream, este é o mais provável. Na ocasião do registro de que um pedido foi finalizado no site, um sistema de pagamento que faz integração com um sistema de terceiro, pode reagir chamando uma API, para processar a transação financeira; um sistema de CRM pode reagir registrando características da venda; e assim por diante.

Seguindo esta linha de raciocínio, no caso de um provedor de cloud computing, por exemplo, vai haver um serviço de provisionamento de infraestrutura, registro de domínios e aplicações SaaS, na expectativa do evento de pagamento realizado ser incluído no log.

Poderia ser também uma campanha de marketing que acabou de ser criada e registrada no log stream, que… okay, você já pegou a ideia.

O ponto crucial aqui é que o sistema que reage a um evento, não está acoplado ao sistema que produz o evento. Idealmente, eles nem se conhecem. Em outras palavras, quem produz o dado não sabe exatamente quem vai consumi-lo e para que finalidade.

A Saída

Suficiente dizer que o céu é o limite.

Vantagens

A principal vantagem que vejo é que uma arquitetura centrada em log stream reduz a complexidade de integração de aplicações a 1-para-1. Em vez de ter que escrever código de integração para se comunicar com N aplicações e sistemas heterogênios, criar processos de ETL, você escreve código para apenas um canal de comunicação, um ponto único de integração. É claro que cada input é um input e você vai ter que lidar com isso semi-individualmente, porque é algo mais relacionado à semântica da comunicação do que qualquer outra coisa. Mas é possível criar alguns padrões para ajudar nisto, apenas tomando o cuidado de não incorrer no erro da generalização generalizada.

A segunda vantagem é o desacoplamento, que até tem um pouco a ver com a vantagem anterior. Mas o ponto aqui é que um sistema não fica dependendo de fazer as chamadas imperativas a outros sistemas diretamente, em uma exata ordem, tendo que lidar com latências diversas, modos de falha, etc. Você registra um fato, notifica a ocorrência de um evento, e o que vai acontecer depois disso torna-se mais flexível do que se você tivesse que fazer uma chamada imperativa dizendo “faça isso agora”.

Finalmente, você tem um registro temporal durável de tudo que aconteceu desde um determinado ponto no tempo, como a nossa história que não pode ser apagada. E como benefício adicional, você tem a possibilidade de voltar no tempo e reprocessar dados desde certo ponto, não alterando a história, que é imutável, mas criando novos registros que são uma correção histórica.

É claro que se você não tem todo espaço em disco do mundo e não tem interesse eterno em tudo que é inserido no log, você pode definir políticas diferenciadas de retenção. O meio do caminho é uma opção. Não precisa ser sempre oito ou oitenta.

Desvantagens

Não é fácil. Não, é sério. Não é fácil. Se te disseram que é fácil, mentiram.

Você precisa de pessoal capacitado, com múltiplos conhecimentos e especialidades. Não dá para pegar meia dúzia de juniores e se colocar a implementar uma arquitetura deste tipo da noite para o dia.

Você tem o custo de infraestrutura, de operação, de mantenção, etc.

O que se precisa analisar é o suposto benefício que se terá com uma arquitetura destas em função de onde a organização está hoje e onde se imagina que ela estará em, digamos, 3-5 anos. Chegariam melhores, com menos dores de cabeça e mais noites bem dormidas se caminhassem para uma arquitetura semelhante a essa?

Decisões de arquitetura não precisam ser todas tomadas no dia 1, podem ser incrementais. Mas um bom roadmap ou um mapa de possíbilidades nunca deixou ninguém com insônia.

Comece seu desenho de arquitetura de sistemas sempre pelo mais simples e vá incrementando. Baby steps. Você vai ver que o mais simples pode ser bem sofisticado, quando permite que a complexidade inevitável seja adicionada aos poucos.

A tecnologia da vez

Provavelmente, você deve ter notado que em momento algum eu cito uma tecnologia específica para implementar esta arquitetura. Nenhuma. Isto porque a minha ideia com este post é tratar a coisa do ponto de vista dos primeiros princípios, dos princípios fundamentais de arquitetura de sistemas e não de tecnologias específicas, que mudam com o tempo.

Como você vai implementar a arquitetura particular da sua organização, tendo domínio conceitual do que pretende fazer e de padrões arquiteturais que emergiram ao longo dos anos, é que é outra coisa. É isto que importa. Esta é a maneira de você não ficar vendido por tecnologias específicas, por vendors, e acabar querendo fazer isso e aquilo por conta de uma tecnologia ou vendedor. Princípios vêm primeiro. Ouch!

Para você ter uma ideia, há 10 anos eu trabalhava em uma empresa onde implementamos um baita sistema usando muitos conceitos que vimos até aqui (propagação de eventos, message brokers, feeds, entre outras coisas), um pipeline bem robusto, sem usar a maioria das tecnologias mais populares de hoje.

O importante é cuidar bem de seus dados na base da pirâmide e estar preparado para escalar com eles para o próximo nível.

Rust | Minhas impressões até então

Desde março de 2018, eu vinha vendo uma coisa aqui, outra ali, sobre a linguagem Rust, após ter visto a apresentação do Florian Gilcher na GOTO 2017, intitulada “Why is Rust successful?”, mas nada realmente sério. Me lembro de ter ficado especialmente empolgado com duas talks do Bryan Cantrill, uma na QCon 2018, “Is it Time to Rewrite the Operating System in Rust?”, em junho de 2019, e outra em um meet up, “The Summer of Rust”, alguns dias depois, mas ainda assim, nada de pegar um livro para ler, de rabiscar algum código.

Histórico do YouTube #1 – Primeiro contato com Rust

Na época eu até tinha uma desculpa compreensível: tinha acabado de completar um bacharelado de Nutrição. Sim, isso mesmo. Quatro anos em uma sala de aula há 17km de casa, lendo um tanto de livros, artigos científicos; fazendo trabalhos, estudando para provas, apresentando seminários; estágios de 6 horas diárias em dois hospitais, uma clínica de nutrição esportiva e uma escola de educação infantil; e ainda o fadigante TCC sobre a relação entre nutrição e depressão. Tudo isso enquanto ajudava a construir a Pricefy do zero.

Dá para imaginar que o tema do TCC veio bem a calhar.

Histórico do YouTube #2 – Talks que por um momento me empolgaram

Mas então virou o ano, chegou 2020, a fadiga mental diminuiu significantemente e resolvi gastar algum tempo com Rust, estudar com um pouco mais de dedicação, rabiscar uns programas, experimentar por mim mesmo e não ficar somente no que vejo da experiência dos outros.

Esse post é para registrar um pouco das minhas impressões até aqui.

Rust, a linguagem

Não quero, aqui, dar uma introdução à linguagem, porque já existe uma documentação oficial maravilhosa, muito material educativo disponível gratuitamente, pois isso seria um tanto redundante.

O que é importante se ter em mente, a princípio, é que Rust foi criada com o objetivo de ser uma linguagem de sistema, para ser usada em casos de uso onde normalmente se usaria C/C++, como: drivers, sistemas embarcados, microcontroladores, bancos de dados, sistemas operacionais; programas que vivem extremamente próximos ao hardware, que requerem alta performance, com baixo consumo de memória e overhead de execução próximo de zero.

Portanto, algumas decisões de design foram:

– Ser compilada para binários nativos;
– Ter um sistema de tipos estático, forte e extensível;
– Não ter coletor de lixo;
– Ter um sistema seguro de gestão de memória;
– Ser imutável por padrão;
– Dar suporte a concorrência imune a data races e race conditions;
– Ter checagem de uso de memória em tempo de compilação;
– Permitir código “não seguro”, quando explicitamente desejado;
– Oferecer tratamento de erro simples, mas robusto;
– Ter um ótimo ferramental de desenvolvimento.

Dentre outras coisas. Esta não é uma lista exaustiva. Mas é o suficiente para contextualizar o que vou falar sobre minhas impressões.

Em poucas palavras, eu diria que o objetivo principal era que ela fosse uma linguagem de baixo nível, extremamente performática, porém absolutamente segura e produtiva.

Vamos então à minhas impressões.

O Compilador

Eu fiquei realmente pirado no compilador. Não, é sério. Tendo gastado boa parte dos últimos anos programando em C#, JavaScript, Go e Python, acho que não preciso dizer muito mais.

Mas vamos ver um exemplo:

O que nos diria o compilador sobre este programinha?

Hmm? E você, o que me diz?

Ao longo do post vão aparecer mais exemplos legais da atuação do compilador, portanto não vou me prolongar aqui.

Imutável por natureza

Variáveis são sempre imutáveis, a menos que explicitamente dito que não, como no caso que vimos há pouco.

Isso favorece o desenvolvimento de código concorrente seguro, o que há muito tem sido um dos principais atrativos de linguagens funcionais – ou melhor dizendo, do paradigma funcional de programação.

Não há porque temer o compartilhamento de valores que não mudam; aliás, que não podem ser modificados. Nenhuma linha de execução vai crashear esperando que a seja "Hello", quando na verdade, agora, a é "Oi".

Possessiva, porém generosa

Agora, espere. O que aconteceria se seguíssemos a sugestão do compilador e tornássemos a variável a mutável?

O efeito colateral seria observado. A variável a poderia ter seu valor modificado e os prints refletiriam isso.

Primeiro porque ela teria sido explicitamente anotada como mutável. Justo. E depois, porque a macro println! faz parte de uma família de casos específicos de macros, em que o parâmetro é implicitamente tomado por referência (a.k.a. borrowing), por questão de conforto, praticidade, mas não causam efeitos colaterais neles.

Okay. Isso coloca em cheque o que vimos no tópico anterior, não? Nhmm… não tão depressa.

Vamos modificar um pouco o exemplo anterior e ver o que aconteceria em uma função que recebe uma variável não por referência, como é o caso da macro println!, mas por transferência de posse (a.k.a. ownership).

O compilador logo chia, dizendo que se está tentando emprestar o valor supostamente possuído pela variável a, para poder modificá-lo, enquanto este, na verdade, teve sua posse transferida para a função awesomely_crazy. Ou seja, o que quer que awesomely_crazy faça com o que recebeu, a variável a não tem mais nada a ver com isso.

O que acontece é que, em Rust, como você já deve ter percebido, um valor só pode ser possuído por uma única variável por vez; e quando o escopo em que esta está contida termina, seu valor é destruído. No entanto, essa posse pode ser cedida a outro.

Quem garante essa coisa de ownership, borrowing e lifetime em tempo de compilação é o chamado borrow checker, que muitas vezes se recusa a compilar um programa que você tem “certeza” que está tudo certo.

No nosso caso, somente a variável a era dona do valor "Hello" até ter transferido sua posse para a função awesomely_crazy. A partir de então, a função awesomely_crazy (nominalmente o parâmetro s) é quem passou a ser sua única proprietária; e ao final de sua execução, ao término de seu escopo, esse valor será destruído. É por isso que ele não pode ser emprestado novamente para modificação, através de a.push_str, ou mesmo emprestada para println!, que sequer modifica alguma coisa.

Portanto, se quisessemos fazer esse código compilar, teríamos que modificar a implementação da função awesomely_crazy, de modo que ela passasse a tomar o valor da variável a emprestado, por referência (&C/C++ feelings, anybody else?), e não por posse.

Não haveria qualquer problema.

Mas note que awesomely_crazy toma o valor de a emprestado, por referência, mas não pode modificá-lo, como é o caso em outras linguagens. Se quiséssemos permitir que awesomely_crazy modifique o valor possuído pela variável a, teríamos que fazer um desencorajador malabarismo de mut, que provavelmente nos faria pensar um pouco mais no algoritmo que estamos tentando escrever.

Eu sei que tudo isso pode parecer complicado (e na prática é mesmo; tente implementar uma estrutura de dados recursiva, por exemplo), mas essas características da linguagem:

– Imutabilidade por padrão;
– Mutabilidade por decisão explícita;
– Posse exclusiva de valor;
– Empréstimo de valor com restrições.

Com regras rigidamente observadas pelo compilador, são super interessantes na hora de escrever programas que rodam continuamente, por tempo indeterminado, sem crashear depois de devorar toda a memória disponível, por causa leaks; ou então, programas com processos concorrentes, que não crasheiam por conta de data races e race conditions.

Essa é a maneira de Rust possibilitar um runtime de alta performace, seguro para processos concorrentes, que não correm o risco de lidar com dangling pointers, data races, e ainda livres do overhead de um coletor de lixo para garantir isso.

Confesso que volta e meia ainda passo perrengue com isso e tenho que repensar meu código, mas isso tem acontecido cada vez menos e tenho gostado cada vez mais. O que realmente me deixa puto são certos casos de inferências, que penso pqp, como é que ele não consegue saber em tempo de compilação o quanto essa p@%$# vai consumir de memória.

Anyway. De qualquer forma, não existe null pointer em Rust e isso por si só já me deixa feliz.

Sintaxe, bizarra sintaxe

Olha quem está falando: alguém que gastou um tanto de horas de sua vida programando em Erlang. Okay. Não tenho muito o que reclamar.

Mas a real é que esse título é mais clickbait do que verídico.

A verdade é que eu gosto da sintaxe de Rust. Sempre fui fã de linguagens com sintaxe C-like, tipo: Java, JavaScript, C#, Scala, Go, e outras. Mas em todas essas linguagens sempre tem alguma coisa que acho chata, irritante ou bizarra. Às vezes as três.

No caso de Rust, o que acho bizarro é a anotação de escopo para ajudar na validação de tempo de vida de referências à memória. Como vimos anteriormente, não há coletor de lixo, então toda referência tem um tempo de vida baseado no escopo em que esta está contida. Terminado o escopo, essa referência é destruída e sua memória liberada. A maior parte do tempo, o compilador consegue inferir isso sem ajuda, mas às vezes, você precisa dar uma mãozinha, usando o chamado generic lifetime parameter, para garantir que em tempo de execução as dadas referências serão de fato válidas.

De novo, não quero aqui, neste curto espaço, me atrever a explicar algo que já está muito bem explicado gratuitamente e online, mas vamos ver um exemplo disso.

Side note – Olha o compilador aí, dando aquela ajuda Google-like

O código abaixo implementa um if-ternário sem sucesso, porque há uma ambiguidade sobre que referência será retornada.

O compilador, naturalmente, não gosta disso, diz o motivo e sugere uma solução.

Implementada a solução sugerida; ou seja, anotado o escopo de vida do que a função recebe e do que retorna, para que a ambiguidade seja eliminada.

Vòila! O código compila e, a menos que tenha um erro de lógica, roda perfeitamente como esperado.

Tudo bem. Foi só uma anotaçãozinha. Mas isso porque também foi só uma funçãozinha. Imagine algo com escopo de vida um pouco mais completo, que precise de mais de uma anotação de lifetime e ainda outras anotações de tipos genéricos.

É, a coisa pode escalar bem rápido. Generics é um recurso fantástico, sem sombra de dúvidas, mas imagine isso na assinatura de uma função, que também tem outros parâmetros, e retorno, e… Pff!

Mas por outro lado, o lado bom, agora, é que há um recurso robusto de definição de tipos. Em lugar de escrever algo assim:

Você pode definir um tipo que defina essa especificação gigantesca, fazendo bom uso de generics e tudo mais; e inclusive, dar um nome para o que ela representa. Só espero que você seja melhor do que eu com nomes.

Vê? Com isso é possível tornar o código bem mais semântico, comunicar mais significado, porque no final das contas, você passa mais tempo lendo código do que escrevendo ou apagando código.

Side note – Outra vez, o compilador amigão

Pattern Matching e tratamento de erros

Quem me conhece e já trocou ideias de programação comigo, sabe que sou bem fã de pattern matching. Essa é uma das coisas que mais gosto em Erlang e é também uma das que mais gosto em Rust.

Lindo, não? Okay. Eu sei que estou exagerando um pouco.

Mas a questão é que este recurso, além de favorecer que se escreva código mais declarativo e com viés mais funcional, também encoraja o tratamento de erros mais simples, menos complicado.

Em Rust, a forma idiomática de tratamento de erros é que o retorno de uma função seja uma enum Result, que pode conter o resultado do sucesso ou o famigerado erro.

Este é um exemplo que peguei da própria documentação da enum Result. Ele dispensa explicação, tão simples e declarativo que é.

Uma coisa interessante para se mencionar aqui, que tem tudo a ver com pattern matching e tratamento de erros, é que enums em Rust estão muito próximas do que em linguagens funcionais chamamos de tipos algébricos, o que favorece muito a expressividade do código.

Okay. Mas voltando à Result e ao tratamento de erros, expandindo um pouco no exemplo acima, retirado da própria documentação, perceba que há uma série de funções bacanas, incluindo map e or_else.

De novo, essa é a maneira idiomática de se lidar com erros em Rust. Sim, existe uma macro panic!, muito parecida com o que há em Go, mas use com moderação.

Aliás, falando em Go, eu tenho que dizer que gosto bastante de Go e prova disso é que desde 2012 tenho estudado e feito Go aqui e ali, quando faz sentido. Dito isso, eu acho a forma idiomática de tratamento de erros super simples e compreensível, porém tosca e pobre. Nada sofisticada. Mas tudo bem, este não é o ponto da linguagem.

Recursos funcionais

Rust não é uma linguagem funcional. Rust não foi desenvolvida para ser uma linguagem funcional. Mas tendo sido significantemente influenciada por programação funcional, Rust oferece muitas ferramentas para que programadores experientes em programação funcional escrevam código com conformação funcional.

– Imutabilidade por padrão;
– Iterators;
– Clousures;
– Block expressions;
– Pattern matching;
– Function composition;
– Tuplas;
– Enumerações.

São todos recursos disponíveis na linguagem, que possibilitam escrever código expressivo, com estilo funcional; e com um detalhe importante: em geral, uma das reclamações que se faz de linguagens funcionais (e não quero aqui discutir isso) é a questão de se preterir performance de execução em função do rigor conceitual do código, mas este não é o caso de Rust, que tem massiva influência da filosofia C++ de custo zero de abstração.

Tudo ao mesmo tempo agora

Concorrência em Rust, na minha visão, não é tão natural como é em Erlang ou Go, mas isso é ok. Por outro lado, Rust oferece mais de uma ferramenta para se implementar concorrência:

– Message passing;
– Shared-state concurrency;
– Futures.

Este é um exemplo simples de message passing que peguei emprestado de Klabnik & Nichols. Bem semelhante ao que se tem em Go, por exemplo, porém um tanto mais verboso.

Aliás, um parênteses aqui: às vezes acho Rust um pouco verbosa de mais. Fecha parênteses.

Este modelo de troca de mensagens é um que me agrada bastante e que estou bem familiarizado. Em muitas situações tendo a pensar primeiro neste modelo antes de considerar outro, porque ele favorece o desacoplamento.

Abaixo, o modelo de compartilhamento de estado entre threads usando Mutex.

Sinceramente, não gosto muito deste modelo. Mas programação não é sempre sobre o que se gosta, mas sobre o que se precisa fazer para ir da maneira mais segura e eficiente possível do ponto A ao ponto B. Então, quando é necessário usar o bom e velho lock, cá está ele à disposição. Implementação de estruturas de dados thread safe, semáforos de acesso a recursos, são exemplos de uso.

Já este exemplo abaixo é de concorrência com futures, usando a relativamente nova implementação de async/await, que também segue a filosofia de abstração com custo zero. Acho que este modelo dispensa qualquer introdução, por ter se popularizado tanto nos últimos anos, desde que foi implementado em F# e C# e mais recentemente em JavaScript.

Pensando bem, nos últimos anos, escrevendo um monte de C# e Node.js quase diariamente (e ocasionalmente algum Python com Async IO/ASGI nos finais de semana) este é o modelo que mais uso. É simples de ler código com async/await, fácil de entender, de explicar, não tem tempo ruim.

É bom ter mais de uma ferramenta à disposição e ser capaz de implementar mais de um modelo de concorrência, para então escolher o que melhor atende à tarefa em questão; e isso não é exclusivo, um ou outro. Em um sistema, pode haver uma combinação desses modelos que vimos. Aliás, esse é o mais provável.

A propósito, se você ainda se confunde um pouco com concorrência vs paralelismo, recomendo 2 minutos de leitura aqui.

Bem, apesar da minha reclamação sobre a verbosidade do message passing de Rust, no final do dia, a conta ainda fica positiva.

Odeio redefinição de variável

Uma coisa que odeio com todas as minhas forças é shadowing de variável.

Pqp, que p%#*@ é essa?!?!?!

Tá. Eu sei que shadowing não é o puro mal encarnado, tem lá sua razão de ser, blá, blá, blá…

I’m done.

Conclusão

Não existe bala de prata e isso você já deveria saber. Também não existe a linguagem perfeita e própria para todas as situações. O que existe são ferramentas em uma caixa; e o que se espera de você é que você saiba escolher a ferramenta certa, para o trabalho certo, na hora certa.

Eu tenho gostado bastante de Rust até então. Tenho tropeçado em alguns pontos aqui e ali, odiado uma coisa ou outra, mas no geral, estou muito satisfeito.

Até aqui.

Composição e Integração de Sistemas em 2013

Hoje estive falando no TDC 2013, na trilha Arquitetura .NET, sobre a estratégia de compor e integrar sistemas a partir de micro-serviços, que é um tema que tenho bastante interesse e pretendo ainda falar um pouco mais a respeito, em eventos e aqui no meu blog.

Esta foi a segunda vez que falei nesse evento e, assim como no ano passado, a sala estava lotada, o pessoal bem interessado e participativo; e no final, uma galerinha veio trocar uma idéia comigo, fazer perguntas, etc. Foi bem bacana mesmo.

Parabéns aos organizadores e ao pessoal que participou!