Na minha jornada de aprendizagem sobre Go e a arte do desenvolvimento de software, estou reescrever uma ferramenta de linha de comandos inicialmente escrita em Bash para Go porque, como de costume, ferramentas que deveriam ser temporárias passam a ser permanentes e, como tal, cresceu demais para ser mantida em Bash. Além disso, estava sem ideias para projetos, logo, decidi seguir em frente com isto.

Esta ferramenta tem como uma das suas funcionalidades principais, procurar por os sitemas desejados numa lista de sistemas obtida atraves de uma API, apresentando na consola as informacoes relevantes sobre os mesmos.

Entretanto, ocorreu-me que seria interessante explorar a possibilidade de tornar a pesquisa concorrente. Não que a performance desta aplicação seja importante, mas porque identifiquei uma oportunidade de sair um pouco mais da zona de conforto e experimentar com um tópico que ainda é bastante confuso para mim. E, potencialmente, melhorar um pouco a aplicação, mesmo não sendo claro se o ganho de desempenho é significativo o suficiente para justificar a sua implementação. Em todo caso, o pior que pode acontecer é deitar fora o código e ficar apenas pela aprendizagem.

O Primeiro teste

A pesquisa é implementada como uma função de pesquisa linear simples. Portanto, decidi adotar a abordagem mais fácil e óbvia, fazer o minimo de alterações no código para chamar a função de forma concorrente e conseguir compará-la com o código inicial.

Para a primeira tentativa, utilizei uma lista de 65 sistemas carregada de um ficheiro JSON e o objetivo era testar duas funções que realizam a mesma tarefa: uma não concorrente e uma concorrente.

Como noob e preguiçoso que sou, em vez de fazer uma mudança completa na função original, criei uma nova função com base na mesma e fiz as alterações desejadas.

// Initial Code
func searchName(name string, systems []System) *System {
	for _, s := range systems {
		if s.Name == name {
			return &s
		}
	}

	return nil
}

func SystemsByName(fileName string, names []string) (systems []System, err error) {
	allSystems, err := AllSystems(fileName)
	if err != nil {
		return nil, err
	}

	for _, name := range names {
		if system := searchName(name, allSystems); system != nil {
			systems = append(systems, *system)
		} else {
			return systems, fmt.Errorf("%w: %s", ErrorNameNotFound, name)
		}
	}

	return systems, nil
}

// The new funcion
func GoSystemsByName(fileName string, names []string) (systems []System, err error) {
	allSystems, err := AllSystems(fileName)
	if err != nil {
		return nil, err
	}

	systemsChan := make(chan System, len(names))
	errorChan := make(chan error, len(names))
	var wg sync.WaitGroup

	for _, name := range names {
		wg.Add(1)
		go func(systems []System, name string) {
			defer wg.Done()

			if system := searchName(name, allSystems); system != nil {
				systemsChan <- *system
			} else {
				errorChan <- fmt.Errorf("%w: %s", ErrorNameNotFound, name)
			}
		}(allSystems, name)
	}

	go func() {
		wg.Wait()
		close(systemsChan)
		close(errorChan)
	}()

	for system := range systemsChan {
		systems = append(systems, system)
	}

	if len(errorChan) > 0 {
		return systems, <-errorChan
	}

	return systems, nil
}

Para o benchmark, procurei por alguns nomes que eu sabia que existiam na lista, assim como um nome que não existia.

// Benchmarking
func BenchmarkSystemsByName(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_, _ = SystemsByName("randomNameX", "randomNameY", "randomNameW", "randomNameZ", "foo")
	}
}

func BenchmarkGoSystemsByName(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_, _ = GoSystemsByName("randomNameA", "randomNameY", "randomNameB", "randomNameG", "foo")
	}
}

Os resultados:

$go test -bench=.
goos: linux
goarch: amd64
pkg: toolbox/pkg/govcm
cpu: 11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz
BenchmarkSystemsByName-8            2815            395575 ns/op
BenchmarkGoSystemsByName-8          2424            491929 ns/op
PASS
ok      toolbox/pkg/govcm       2.409s

Não-Concorrente: 2815 operações, com uma média de 395575 ns por operação.

Função concorrente: 2424 operações, com uma média de 491929 ns por operação.

Bem… Por esta é que eu não esperava! A versão não concorrente teve um desempenho melhor do que a versão concorrente. Mas… Isto não bate certo. A pesquisa concorrente deveria ser mais rápida, certo? Onde é que errei?!

Como sou uma criatura movida pela curiosidade, decidi continuar a explorar e testar a próxima coisa mais óbvia.

Mais dados!

Provavelmente é mesmo o meu código que não presta, mas, por uma questão de simplicidade, vamos assumir que não! Afinal, quando criei a função, adicionei alguns prints e vi que estava a trabalhar como pretendido, além de ter passado em todos os testes no código original! Afinal de contas, desde quando é que isto nunca falhou? Bom, prossigamos…

A próxima coisa mais óbvia é testar com mais dados e tentar encontrar uma inversão nos valores. Pelo que sei, a concorrência também adiciona algum overhead, o que pode não ser benéfico para menores quantidades de dados.

Depois de ler um pouco mais sobre benchmarking em Go e encontrar o benchstat, decidi mudar um pouco a forma como estava a fazer o benchmark, garanto que tudo é testado da mesma maneira, facilitando a análise dos resultados. Reescrevi os testes para testar apenas a função SystemsByName() e a função concorrente e não concorrente têm o mesmo nome, sendo que uma delas está sempre comentada, dependendo do que quero testar.

Para os tests, decidi fazer o benchmark com listas de 100, 1000, 10000, 100000 e 1000000 sistemas. Em cada teste, vou procurar pelo primeiro elemento da lista, um elemento no meio da lista, o último elemento da lista e um elemento que não existe.

package main

import "testing"

const (
	file100 = "data/systems_100.json"
	file1000 = "data/systems_1000.json"
	file10000 = "data/systems_10000.json"
	file100000 = "data/systems_100000.json"
	file1000000 = "data/systems_1000000.json"
)

// Benchmarking
func BenchmarkNameSearch(b *testing.B){
	b.Run("100 Systems", func (b *testing.B) {
		names := []string{"system0", "system50", "system99", "foo"}
		for i := 0; i < b.N; i++ {
			_, _ = SystemsByName(file100, names)
		}
	})

	b.Run("1000 Systems", func (b *testing.B) {
		names := []string{"system0", "system500", "system999", "foo"}
		for i := 0; i < b.N; i++ {
			_, _ = SystemsByName(file1000, names)
		}
	})

	b.Run("10000 Systems", func (b *testing.B) {
		names := []string{"system0", "system5000", "system9999", "foo"}
		for i := 0; i < b.N; i++ {
			_, _ = SystemsByName(file10000, names)
		}
	})

	b.Run("100000 Systems", func (b *testing.B) {
		names := []string{"system0", "system50000", "system99999", "foo"}
		for i := 0; i < b.N; i++ {
			_, _ = SystemsByName(file100000, names)
		}
	})

	b.Run("1000000 Systems", func (b *testing.B) {
		names := []string{"system0", "system50000", "system999999", "foo"}
		for i := 0; i < b.N; i++ {
			_, _ = SystemsByName(file1000000, names)
		}
	})
}

Agora que decidi como quero fazer o benchmark, só preciso mesmo das listas. Para esse efeito, criei um script em Python relativamente simples para gerar vários arquivos JSON formatados de forma semelhante ao JSON retornado pela API original, mas com quantidades diferentes de sistemas.

#!/bin/python

import json
import uuid
import random
import sys

total = int(sys.argv[1])
file=(f'systems_{total}.json')
regions = ["eu-north-1", "eu-west-1", "eu-west-2", "us-west-1"]
states = ["STATE", "PLAN_FAILED", "RESUME_FAILED", "PAUSED", "STORAGE_CREATION_FAILED", "FOO_FAILED"]
auxStates = ["PLANING", "RESUMING", "PAUSING", "TO_BE_PLANED", "TO_BE_PAUSED"]
types = ["team", "lab"]
versions = ["1.2.3-gec414e3c24", "2.3.2-bde194c123a", "3.4.0-foobarbaz"]
items = []

for i in range(total):
    item = {
        "id" : str(uuid.uuid4()),
        "name" : (f'system{i}'),
        "region" : random.choice(regions),
        "state" : random.choice(states),
        "auxState" : random.choice(auxStates),
        "apiEndpoint" : (f'https://system{i}.mydomain.com'),
        "systemType" : random.choice(types),
        "systemVersion" : random.choice(versions)
    }
    items.append(item)

data = {"items": items}

with open(file, "w") as f:
    json.dump(data, f, indent=2)

Agora que tenho os dados, é hora de executar os testes, também com parâmetros diferentes: padrão, 20 segundos, 30 segundos, 20 vezes e 30 vezes. É importante realçar que o ambiente de teste não é perfeito, mas o mundo também não o é, e não estou a procura de resultados super detalhados. Fico satisfeito se encontrar algumas pistas sobre o benefício da concorrência em funções de procura linear e, se sim, a que ponto.

Benchstat

Não vou colocar todos os resultados aqui, mas os resultados foram consistentes em todas as execuções.

$benchstat test_default.txt test_concurrent_default.txt
goos: linux
goarch: amd64
pkg: concurrency
cpu: 11th Gen Intel(R) Core(TM) i5-1135G7 @ 2.40GHz
                              test_default.txt      test_concurrent_default.txt      
                                   sec/op          sec/op     vs base                
NameSearch/100_Systems-8           248.9µ ±  ¹   262.5µ ±  ¹       ~ (p=1.000 n=1) ²
NameSearch/1000_Systems-8          2.445m ±  ¹   2.604m ±  ¹       ~ (p=1.000 n=1) ²
NameSearch/10000_Systems-8         26.01m ±  ¹   26.33m ±  ¹       ~ (p=1.000 n=1) ²
NameSearch/100000_Systems-8        259.4m ±  ¹   259.0m ±  ¹       ~ (p=1.000 n=1) ²
NameSearch/1000000_Systems-8        2.553 ±  ¹    2.534 ±  ¹       ~ (p=1.000 n=1) ²
geomean                            25.36m         25.97m        +2.42%
¹ need >= 6 samples for confidence interval at level 0.95
² need >= 4 samples to detect a difference at alpha level 0.05

Embora os resultados do benchmark mostrem que a pesquisa não concorrente é geralmente mais eficiente do que a pesquisa concorrente, é possível observar que, após atingir 100.000 itens, começa a ocorrer uma inversão. Neste ponto, a pesquisa concorrente é ligeiramente mais eficiente, embora a diferença não seja significativa.

Dito isto, só porque a concorrência está amplamente disponível e é relativamente fácil de implementar, não significa que esta tenha que ser sempre utilizada. Devemos estar atentos a quando e como devemos fazer uso de concorrência, porque na verdade, em vez de ajudar, pode mesmo prejudicar o desempenho.

Calma, Karen, espera, antes de começares a atirar pedras… quando digo desempenho, não me refiro apenas ao desempenho do código. O desempenho da sua equipa pode sofrer também. Neste caso, por exemplo, ao optar por uma solução concorrente, os teus colegas agora têm que lidar com a complexidade adicional no código sem qualquer tipo de benefício real. Pode-se até argumentar que, no futuro, ao lidar com conjuntos de dados maiores que 10.000 itens, será melhor estudar e aprender sobre outros algoritmos mais eficientes e que se beneficiem mais da concorrência.

Talvez uma história para outra altura!

Se chegaram até aqui, parabéns! Espero que tenha sido pelo menos útil. Para mim estes resultados não fazem muito sentido, logo acho que está na altura de estudar um pouco mais.

Os meus resultados estão muito distantes da realidade? Estou completamente errado? O que é que me está a escapar? Sintam-se à vontade para entrar em contacto comigo e me dizer o que pensam!

O código e os resultados dos testes podem ser encontrados aqui