Golang 09 — Goroutines – Concorrência e Paralelismo
- Golang – 01. Introdução
- Golang – 02. Tipos Básicos
- Golang – 03. Structs e Funções e Métodos
- Golang – 04. Estruturas de Controle
- Golang 05 — Pacotes em Go
- Golang 06 — Módulos e dependências
- Golang – 06. Biblioteca Padrão I – fmt e strings
- Golang – 07. Biblioteca Padrão II – os, os/exec e os/user
- Golang 08 — Interface
- Golang 09 — Goroutines – Concorrência e Paralelismo
- Golang – 10. Goroutines – Canais
- Golang – 11. Pacotes e Documentação no Go
Índice
Muitas vezes se confunde concorrência com paralelismo, embora sejam conceitos distintos. Um dos criadores da linguagem Go resumiu bem essa diferença em uma de suas apresentações:
“Concorrência é sobre lidar com várias coisas ao mesmo tempo e paralelismo é sobre fazer várias coisas ao mesmo tempo.” – Rob Pike, na conferência Heroku’s Waza, na apresentação Concurrency is not parallelism
A ideia central é que concorrência está relacionada à organização de várias tarefas em andamento, enquanto paralelismo está relacionado à execução simultânea dessas tarefas. Em outras palavras, um programa pode ser concorrente mesmo quando executado em apenas um núcleo de processamento. Já o paralelismo exige que mais de uma tarefa seja executada fisicamente ao mesmo tempo, normalmente em núcleos diferentes da CPU.
O diagrama abaixo ilustra três situações: concorrência em um único núcleo, paralelismo em dois núcleos e a combinação de concorrência com paralelismo em uma máquina com mais de um núcleo.

Concorrência, paralelismo e concorrência com paralelismo em uma máquina com um ou mais núcleos. As bolinhas de diferentes cores representam tarefas sendo executadas.
Na concorrência, as tarefas 1 e 2 avançam de forma intercalada. Isso significa que uma tarefa executa por um pequeno intervalo, é interrompida, e outra tarefa passa a executar. Depois, a primeira pode continuar a partir do ponto em que parou. Para o usuário, essa alternância costuma dar a impressão de que várias coisas acontecem ao mesmo tempo, embora apenas uma esteja usando aquele núcleo em cada instante.
De forma simplificada:
- a tarefa 1 executa por um intervalo;
- em seguida, sua execução é suspensa;
- a tarefa 2 passa a executar;
- depois, a tarefa 2 também é suspensa;
- a tarefa 1 continua sua execução;
- e assim sucessivamente.
Dessa forma, um único núcleo pode manter diversas tarefas em andamento, como a execução de programas, a leitura do teclado, o uso do mouse, a reprodução de áudio, a comunicação pela rede e outras atividades do sistema operacional.
Já o paralelismo ocorre quando duas ou mais tarefas são executadas simultaneamente. Para isso, é necessário haver recursos físicos disponíveis, como múltiplos núcleos de processamento. Em máquinas modernas, concorrência e paralelismo normalmente coexistem: o sistema operacional alterna entre várias tarefas concorrentes e, ao mesmo tempo, distribui parte delas entre diferentes núcleos da CPU.
A discussão sobre concorrência e paralelismo é extensa e envolve escalonamento de tarefas, uso eficiente da CPU, operações de entrada e saída, acesso à memória, sincronização e coordenação entre partes do programa. Em Go, esses conceitos aparecem de forma prática por meio das goroutines e dos canais, que oferecem uma maneira simples e expressiva de estruturar programas concorrentes.
Alguns bons materiais sobre o assunto são:
- Concurrency is not Parallelism, que apresenta uma visão simples e clara sobre concorrência e paralelismo;
- o artigo de Tony Hoare, de 1978, que apresenta o modelo de concorrência que inspirou parte da abordagem adotada em Go;
- Concorrência, Paralelismo, Processos, Threads, programação síncrona e assíncrona, texto em português que traz uma discussão mais ampla sobre threads, sistemas multitarefa e programação assíncrona.
Goroutines
Em Go, a concorrência é tratada como um elemento central da linguagem. Em vez de depender diretamente de threads do sistema operacional, Go introduz o conceito de goroutines, que são unidades leves de execução gerenciadas pela própria runtime da linguagem.
Uma goroutine pode ser entendida como uma função que é executada de forma concorrente com o restante do programa. A criação de uma goroutine é simples e explícita, feita com a palavra-chave go antes da chamada de uma função:
go minhaFuncao()
Ao executar essa instrução, a função minhaFuncao passa a ser executada de forma concorrente, sem bloquear o fluxo principal do programa.
Diferentemente das threads tradicionais, as goroutines possuem um custo muito baixo de criação e gerenciamento. Enquanto threads do sistema operacional envolvem chamadas ao kernel e estruturas mais pesadas, as goroutines são multiplexadas pela runtime de Go sobre um conjunto menor de threads reais. Esse modelo é conhecido como M:N, onde várias goroutines são executadas sobre um número reduzido de threads do sistema.
Isso permite que programas em Go criem milhares — ou até milhões — de goroutines sem o mesmo impacto que haveria ao criar a mesma quantidade de threads diretamente no sistema operacional.
A execução dessas goroutines é coordenada por um escalonador (scheduler) presente na runtime de Go. Esse escalonador é responsável por distribuir as goroutines entre as threads disponíveis, equilibrando a carga de trabalho e aproveitando os múltiplos núcleos da CPU quando disponíveis.
Na prática, isso significa que o desenvolvedor não precisa gerenciar explicitamente threads, pools ou políticas de escalonamento. A responsabilidade de decidir quando e onde cada goroutine será executada é delegada à runtime, permitindo que o código se concentre na estrutura lógica da concorrência, e não nos detalhes de baixo nível da execução.
Apesar da simplicidade na criação de goroutines, é importante entender que elas não possuem, por si só, qualquer mecanismo de coordenação entre si. Quando múltiplas goroutines acessam dados compartilhados ou precisam trocar informações, torna-se necessário definir como essa comunicação será feita.
Em modelos tradicionais baseados em threads, essa coordenação costuma ser feita por meio de mecanismos como mutexes, locks e variáveis de condição. Go também oferece esses recursos, mas a linguagem incentiva uma abordagem diferente, baseada na comunicação entre goroutines.
Essa abordagem é frequentemente resumida na seguinte ideia:
“Não compartilhe memória para se comunicar. Comunique-se para compartilhar memória.”
Em vez de múltiplas goroutines acessarem diretamente uma mesma estrutura de dados, a comunicação é feita por meio de canais (channels), que permitem o envio e o recebimento de valores de forma segura e sincronizada.
Antes de entrar nos detalhes dos canais, é importante observar um aspecto fundamental do modelo de execução: a criação de uma goroutine não garante quando ela será executada. O momento exato em que uma goroutine começa, pausa ou retoma sua execução depende do escalonador da runtime.
Considere o exemplo:
package main
import "fmt"
func main() {
go fmt.Println("goroutine")
fmt.Println("main")
}A saída desse programa não é determinística. Em algumas execuções, pode-se observar:
main
E, em outras:
main goroutine
Isso ocorre porque a função main pode terminar sua execução antes que a goroutine tenha a chance de ser agendada e executada. Quando a função main termina, o programa é encerrado, independentemente das goroutines ainda em execução.
Esse comportamento evidencia dois pontos importantes:
- goroutines são executadas de forma concorrente, mas não há garantia de ordem de execução;
- o ciclo de vida do programa está associado à função
main, e não às goroutines criadas.
Para lidar com esse tipo de situação, é necessário introduzir mecanismos de coordenação explícitos, que permitam controlar o momento em que goroutines devem iniciar, sincronizar ou finalizar sua execução. Esses mecanismos serão explorados a seguir, com o uso de canais e outras ferramentas da biblioteca padrão.
Canais
Canais (channels) são o principal mecanismo de comunicação entre goroutines em Go. Eles permitem enviar e receber valores de forma segura, coordenando a execução concorrente sem a necessidade explícita de locks.
Um canal possui um tipo associado e pode ser criado com a função make:
ch := make(chan int)
Nesse exemplo, ch é um canal capaz de transportar valores do tipo int.
A comunicação através de um canal é feita por duas operações fundamentais:
- envio de um valor para o canal;
- recebimento de um valor do canal.
ch <- 10 // envio valor := <-ch // recebimento
Um aspecto importante é que essas operações são, por padrão, bloqueantes. Isso significa:
- o envio (
ch <- valor) bloqueia até que alguma goroutine esteja pronta para receber; - o recebimento (
<-ch) bloqueia até que algum valor esteja disponível no canal.
Esse comportamento estabelece automaticamente um ponto de sincronização entre as goroutines.
Considere o exemplo:
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
ch <- 42
}()
valor := <-ch
fmt.Println(valor)
}Nesse caso, a goroutine envia o valor 42 para o canal, enquanto a função main aguarda o recebimento desse valor. O programa só continua quando ambas as operações se encontram, garantindo que o valor seja transferido corretamente.
Diferentemente de abordagens baseadas em memória compartilhada, onde múltiplas threads acessam os mesmos dados, o uso de canais promove um fluxo mais explícito: os dados são passados entre as partes do programa, em vez de serem compartilhados diretamente.
Essa característica reduz a necessidade de controle manual de concorrência e torna o comportamento do programa mais previsível, especialmente em cenários com múltiplas goroutines interagindo.
Nos próximos exemplos, serão exploradas variações desse mecanismo, incluindo canais com buffer, fechamento de canais e o uso de select para lidar com múltiplas comunicações simultâneas.
Canais com buffer
Até agora, os exemplos utilizaram canais sem buffer, onde envio e recebimento precisam ocorrer ao mesmo tempo para que a operação seja concluída. Esse comportamento impõe uma sincronização direta entre as goroutines.
Go também permite a criação de canais com buffer, que introduzem uma fila interna de valores. Nesse caso, o envio não bloqueia imediatamente, desde que ainda exista espaço disponível no buffer.
A criação de um canal com buffer é feita informando sua capacidade:
ch := make(chan int, 2)
Nesse exemplo, o canal pode armazenar até dois valores antes de bloquear novas operações de envio.
Considere o seguinte código:
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 1
ch <- 2
fmt.Println(<-ch)
fmt.Println(<-ch)
}Aqui, os dois envios são realizados sem bloqueio, pois o buffer comporta ambos os valores. Apenas quando o buffer estiver cheio, uma nova tentativa de envio será bloqueada até que algum valor seja removido do canal.
Esse comportamento altera a forma como as goroutines se sincronizam. Em vez de uma sincronização estrita entre envio e recebimento, o buffer permite um desacoplamento parcial entre produtores e consumidores.
De forma resumida:
- canais sem buffer sincronizam diretamente duas goroutines;
- canais com buffer permitem acumular valores antes da sincronização.
Apesar dessa flexibilidade, o uso de buffers exige cuidado. Um buffer muito grande pode mascarar problemas de coordenação entre goroutines, enquanto um buffer muito pequeno pode levar a bloqueios frequentes.
Em geral, a escolha entre canais com ou sem buffer deve refletir o padrão de comunicação desejado, e não apenas uma tentativa de evitar bloqueios.
Fechamento de canais
Além do envio e recebimento de valores, canais também podem ser fechados. O fechamento indica que não haverá mais envios para aquele canal.
close(ch)
Após o fechamento, qualquer tentativa de envio para o canal resultará em erro em tempo de execução. Por outro lado, operações de leitura continuam válidas: é possível consumir os valores que ainda estão no canal até que ele seja esvaziado.
Considere o exemplo:
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 1
ch <- 2
close(ch)
fmt.Println(<-ch)
fmt.Println(<-ch)
}Nesse caso, os valores armazenados no buffer ainda podem ser lidos normalmente, mesmo após o fechamento do canal.
Uma vez esvaziado, leituras adicionais retornam o valor zero do tipo associado ao canal. Para distinguir esse caso de um valor válido, Go permite verificar explicitamente se o canal ainda está aberto:
valor, ok := <-ch
valorrecebe o dado lido;okserátruese o canal ainda estiver aberto;okseráfalsequando o canal estiver fechado e não houver mais valores a serem lidos.
Esse padrão é especialmente útil em loops de consumo:
for {
valor, ok := <-ch
if !ok {
break
}
fmt.Println(valor)
}Uma forma mais idiomática de escrever esse padrão é utilizando range sobre o canal:
for valor := range ch {
fmt.Println(valor)
}Nesse caso, o loop termina automaticamente quando o canal é fechado e todos os valores foram consumidos.
O fechamento de canais é uma forma de sinalizar o término de uma produção de dados. Em geral, a responsabilidade de fechar o canal pertence à goroutine que realiza os envios, e não àquela que consome os valores.
Select
Quando um programa utiliza múltiplos canais, torna-se necessário lidar com diferentes possíveis pontos de comunicação. O comando select permite aguardar por múltiplas operações de envio e recebimento, executando aquela que estiver pronta primeiro.
Considere o exemplo:
package main
import "fmt"
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
ch1 <- 1
}()
go func() {
ch2 <- 2
}()
select {
case v1 := <-ch1:
fmt.Println("ch1:", v1)
case v2 := <-ch2:
fmt.Println("ch2:", v2)
}
}Nesse caso, o select aguarda até que uma das operações esteja pronta. Assim que um dos canais estiver disponível para leitura, o respectivo bloco é executado.
Se múltiplos casos estiverem prontos ao mesmo tempo, a escolha é feita de forma não determinística.
O select também pode ser usado com a cláusula default, que permite evitar bloqueio:
select {
case v := <-ch:
fmt.Println(v)
default:
fmt.Println("nenhum valor disponível")
}Nesse exemplo, se não houver valor disponível no canal, o bloco default será executado imediatamente, sem bloqueio.
Outro uso comum do select é implementar timeouts:
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int)
select {
case v := <-ch:
fmt.Println(v)
case <-time.After(time.Second):
fmt.Println("timeout")
}
}Aqui, o programa aguarda um valor no canal ch, mas abandona a espera caso o tempo limite seja atingido.
O select é uma ferramenta central na construção de programas concorrentes em Go, pois permite coordenar múltiplas goroutines de forma controlada e expressiva, sem recorrer a mecanismos explícitos de bloqueio.
Padrões básicos de comunicação
Com goroutines e canais, alguns padrões de comunicação aparecem com frequência. Esses padrões ajudam a estruturar programas concorrentes de forma mais previsível, evitando compartilhamento direto de memória e reduzindo a complexidade de sincronização.
Um dos padrões mais simples é o de produtor e consumidor. Uma goroutine produz dados e os envia para um canal, enquanto outra goroutine consome esses dados.
package main
import "fmt"
func produtor(ch chan int) {
for i := 0; i < 3; i++ {
ch <- i
}
close(ch)
}
func consumidor(ch chan int) {
for v := range ch {
fmt.Println(v)
}
}
func main() {
ch := make(chan int)
go produtor(ch)
consumidor(ch)
}Nesse caso, o canal atua como meio de comunicação entre as duas goroutines, garantindo que os valores sejam transferidos de forma segura e ordenada.
Outro padrão comum é o de fan-out, onde múltiplas goroutines consomem dados a partir de uma mesma fonte, distribuindo o trabalho.
for i := 0; i < 3; i++ {
go consumidor(ch)
}Aqui, várias goroutines competem pela leitura no mesmo canal, processando os dados conforme ficam disponíveis. Isso permite paralelizar o processamento de uma sequência de tarefas.
O padrão complementar é o fan-in, onde múltiplas goroutines produzem dados que são agregados em um único canal.
func fanIn(ch1, ch2 chan int, out chan int) {
go func() {
for v := range ch1 {
out <- v
}
}()
go func() {
for v := range ch2 {
out <- v
}
}()
}Nesse caso, valores provenientes de diferentes fontes são combinados em um único fluxo de saída.
Esses padrões mostram uma característica importante do modelo de concorrência em Go: o foco está no fluxo de dados entre as partes do programa. Em vez de coordenar diretamente o acesso a estruturas compartilhadas, a comunicação define a estrutura da execução.
À medida que o sistema cresce, esses padrões podem ser combinados para formar pipelines mais complexos, onde diferentes etapas do processamento são encadeadas por canais, cada uma executando em uma ou mais goroutines.
Sincronização explícita
Embora canais sejam o mecanismo central de comunicação em Go, nem todos os cenários são naturalmente modelados apenas com troca de mensagens. Em alguns casos, é necessário sincronizar explicitamente o término de goroutines ou coordenar pontos específicos da execução.
Um recurso comum para isso é o sync.WaitGroup, que permite aguardar a conclusão de um conjunto de goroutines.
package main
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("worker", id)
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go worker(i, &wg)
}
wg.Wait()
fmt.Println("fim")
}Nesse exemplo, o WaitGroup mantém um contador interno. Cada chamada a Add(1) indica a criação de uma nova goroutine a ser aguardada. Dentro de cada goroutine, Done() reduz esse contador. A chamada Wait() bloqueia até que o contador chegue a zero.
Esse padrão é útil quando o objetivo é aguardar o término de várias tarefas, sem necessariamente trocar dados entre elas.
Outro mecanismo disponível é o uso de mutexes (sync.Mutex), que permitem controlar o acesso concorrente a uma região crítica:
var mu sync.Mutex
var contador int
func incrementar() {
mu.Lock()
contador++
mu.Unlock()
}Nesse caso, o mutex garante que apenas uma goroutine por vez possa modificar a variável contador, evitando condições de corrida.
Embora esses mecanismos sejam importantes, o uso excessivo de memória compartilhada e locks tende a aumentar a complexidade do código. Por isso, a abordagem idiomática em Go prioriza o uso de canais para estruturar a comunicação, recorrendo a WaitGroup e Mutex quando necessário.
Na prática, programas concorrentes reais frequentemente combinam esses recursos: canais para fluxo de dados, WaitGroup para controle de ciclo de vida e, em casos específicos, mutexes para proteger estado compartilhado.
Perfeito — seguem os quatro acréscimos, no mesmo padrão e progressão do texto.
Scheduler e modelo de execução
A execução de goroutines é gerenciada por um escalonador (scheduler) presente na runtime de Go. Esse componente é responsável por distribuir as goroutines entre as threads do sistema operacional, utilizando um modelo conhecido como G-M-P:
- G (goroutine): unidade de execução leve;
- M (machine): thread do sistema operacional;
- P (processor): contexto de execução que associa goroutines a threads.
Cada P mantém uma fila de goroutines prontas para execução, e o scheduler distribui essas goroutines entre as threads disponíveis, buscando equilíbrio de carga e eficiência no uso da CPU.
Em versões modernas de Go, o scheduler também realiza preempção, permitindo interromper goroutines de longa duração para evitar que monopolizem a CPU. Isso é especialmente importante em tarefas CPU-bound, garantindo que outras goroutines tenham oportunidade de execução.
Na prática, isso significa que:
- goroutines são multiplexadas sobre um conjunto menor de threads;
- a execução é gerenciada automaticamente pela runtime;
- não há garantia de ordem ou prioridade explícita.
Esse modelo reforça a ideia de que concorrência em Go é baseada em estrutura de execução, e não em controle manual de threads.
Contexto e cancelamento
Em sistemas concorrentes reais, não basta apenas iniciar goroutines — é necessário também controlar seu ciclo de vida. O pacote context fornece um mecanismo padrão para propagação de cancelamento, prazos (deadlines) e informações de contexto entre goroutines.
Considere o exemplo:
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context) {
select {
case <-time.After(2 * time.Second):
fmt.Println("trabalho concluído")
case <-ctx.Done():
fmt.Println("cancelado:", ctx.Err())
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
go worker(ctx)
time.Sleep(3 * time.Second)
}Nesse caso, a goroutine worker aguarda a conclusão do trabalho ou o cancelamento do contexto. Como o tempo limite é de 1 segundo, o contexto é cancelado antes que o trabalho termine.
O uso de context é essencial para evitar goroutines que permanecem ativas indefinidamente, especialmente em aplicações que lidam com I/O, requisições externas ou pipelines complexos.
Deadlocks e bloqueios
Um dos problemas mais comuns em programas concorrentes é o deadlock, que ocorre quando duas ou mais goroutines ficam bloqueadas esperando indefinidamente por eventos que nunca acontecerão.
Considere o exemplo:
package main
func main() {
ch := make(chan int)
ch <- 1
}Nesse caso, a operação de envio bloqueia, pois não há nenhuma goroutine pronta para receber o valor. Como a função main está bloqueada, o programa entra em deadlock e é encerrado com erro pela runtime.
Outro cenário comum envolve múltiplas goroutines esperando umas pelas outras, formando um ciclo de dependência.
Deadlocks geralmente indicam um problema no desenho da comunicação entre goroutines. Em Go, eles são detectados em tempo de execução quando todas as goroutines estão bloqueadas.
Além disso, há problemas mais sutis, como goroutine leaks, onde goroutines permanecem bloqueadas indefinidamente por falta de sinalização adequada, consumindo recursos ao longo do tempo.
Pipeline de processamento
Um padrão comum em programas concorrentes é o uso de pipelines, onde o processamento é dividido em etapas encadeadas, cada uma executando em uma ou mais goroutines e se comunicando por canais.
Considere o exemplo:
package main
import "fmt"
func gerar(n int) <-chan int {
out := make(chan int)
go func() {
for i := 0; i < n; i++ {
out <- i
}
close(out)
}()
return out
}
func dobrar(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for v := range in {
out <- v * 2
}
close(out)
}()
return out
}
func main() {
for v := range dobrar(gerar(5)) {
fmt.Println(v)
}
}Nesse caso, a função gerar produz valores, que são consumidos pela função dobrar, formando um pipeline simples de duas etapas.
Cada etapa é responsável por uma transformação específica, e a comunicação entre elas é feita exclusivamente por canais. Esse modelo favorece a composição de etapas independentes, facilitando a paralelização e a organização do código.
Pipelines mais complexos podem incluir múltiplas etapas, paralelismo em cada estágio e mecanismos de cancelamento com context, formando a base de muitos sistemas concorrentes em Go.
Considerações finais
Concorrência em Go não é apenas uma questão de desempenho, mas principalmente de estruturação do programa. O uso de goroutines e canais permite organizar tarefas independentes de forma clara, separando responsabilidades e definindo explicitamente como os dados fluem entre as partes do sistema.
Ao longo desta seção, alguns princípios fundamentais aparecem de forma recorrente:
- concorrência não implica paralelismo;
- a execução é, em geral, não determinística;
- a coordenação deve ser explícita;
- o fluxo de dados é mais importante do que o compartilhamento de memória.
Esses princípios refletem diretamente o modelo adotado pela linguagem. Em vez de expor detalhes de baixo nível, como gerenciamento de threads, políticas de escalonamento ou controle direto de execução, Go delega essas decisões à runtime, que organiza a execução das goroutines por meio de seu scheduler. Ao desenvolvedor, cabe estruturar corretamente a comunicação e o fluxo de dados.
Isso não elimina a complexidade inerente à concorrência. Problemas como deadlocks, condições de corrida, bloqueios indevidos e goroutines que não finalizam continuam possíveis e exigem atenção no desenho do sistema. O uso de context para controle de cancelamento e de padrões como pipelines ajuda a tornar o comportamento do programa mais previsível e controlado, especialmente em aplicações que lidam com múltiplas etapas de processamento ou operações externas.
Na prática, programas concorrentes reais combinam diferentes mecanismos: canais para comunicação, WaitGroup para controle de ciclo de vida, context para cancelamento e, quando necessário, mutexes para proteger estado compartilhado. O uso adequado dessas ferramentas depende mais do modelo de comunicação adotado do que de decisões isoladas de implementação.
Em termos práticos, a principal mudança de perspectiva é sair de um modelo centrado em “quem acessa o quê” para um modelo centrado em “quem envia o quê para quem”. Essa mudança reduz o acoplamento entre as partes do programa e tende a produzir código mais legível, mais modular e mais fácil de evoluir.
Por fim, é importante lembrar que concorrência deve ser usada quando há um ganho real na estrutura ou no desempenho do programa. Introduzir goroutines sem necessidade pode tornar o código mais difícil de entender e depurar. Como em outros aspectos do desenvolvimento, a simplicidade continua sendo um critério essencial.
Deixe uma resposta