Voltar ao blog
31 de maio de 20265 min de leitura

Lambda Warm Instances: Pool de Instâncias e Processamento Assíncrono em Go

goconcurrencyperformanceprogramming

A primeira invocação de uma Lambda leva 3 segundos. A segunda também. A terceira também. São 3 segundos de cold start toda vez porque cada invocação pode subir um container do zero. Em um fluxo com 20 chamadas encadeadas, são 60 segundos só de overhead de inicialização. A ideia do pool é manter instâncias prontas para reutilização: a primeira chamada paga o cold start, as próximas reutilizam a instância aquecida e respondem em milissegundos. Combinado com processamento assíncrono via workers, o sistema consegue executar múltiplas invocações em paralelo sem bloquear o fluxo principal.

Guia de tópicos:

  • O Problema: Cold Start em Lambda
  • Agent Pool: Registro e Reutilização
  • Lifecycle de uma Instância
  • Cleanup Automático de Instâncias Ociosas
  • Connection Pool com Workers
  • Async Processor: Submit e Collect
  • Timeout por Chamada com Context
  • Considerações Finais
  • Links Indicativos

O Problema: Cold Start em Lambda

Quando uma Lambda é invocada a frio, a AWS precisa: provisionar um container, carregar o runtime (Node, Python, Go), inicializar o execution environment, carregar suas dependências, e só então executar o handler. Dependendo do runtime e do tamanho do pacote, esse setup leva entre 1 e 4 segundos.

Para uma invocação isolada, 2 segundos é aceitável. Para um pipeline onde um serviço chama 20 Lambdas em sequência ou onde um usuário dispara ações repetidas em modo interativo o overhead acumula e a experiência degrada.

A AWS oferece Provisioned Concurrency como solução oficial: você paga para manter instâncias aquecidas. Mas para cenários onde você controla o caller, dá para implementar o mesmo conceito no nível da aplicação: um pool que gerencia instâncias de execução e as reutiliza entre chamadas, evitando pagar o custo de cold start repetidamente.


Agent Pool: Registro e Reutilização

O pool mantém um registro de instâncias por ID de agente:

type AgentInstance struct {
    ID       string
    LastUsed time.Time
    InUse    bool
    Context  string
}

type AgentPool struct {
    instances map[string]*AgentInstance
    mutex     sync.RWMutex
    maxIdle   time.Duration
}

func NewAgentPool() *AgentPool {
    pool := &AgentPool{
        instances: make(map[string]*AgentInstance),
        maxIdle:   10 * time.Minute,
    }

    go pool.cleanup()
    return pool
}

Cada agente tem um ID único (por exemplo, "invoice_processor" ou "user/validator"). O pool mapeia IDs para instâncias. Quando alguém pede uma instância, o pool verifica se já existe uma disponível (não em uso). Se existe, marca como em uso e retorna. Se não existe, cria uma nova pagando o cold start uma única vez.

O maxIdle define quanto tempo uma instância pode ficar ociosa antes de ser descartada. 10 minutos é o default: se ninguém usa aquele agente por 10 minutos, ele é removido. Isso evita acumular instâncias de funções que foram chamadas uma vez e nunca mais.


Lifecycle de uma Instância

O ciclo de vida é: criação, uso, liberação, e eventual remoção:

func (p *AgentPool) GetOrCreate(agentID string) (*AgentInstance, error) {
    p.mutex.Lock()
    defer p.mutex.Unlock()

    if instance, exists := p.instances[agentID]; exists {
        if !instance.InUse {
            instance.InUse = true
            instance.LastUsed = time.Now()
            return instance, nil
        }
    }

    instance, err := p.createInstance(agentID)
    if err != nil {
        return nil, err
    }

    p.instances[agentID] = instance
    return instance, nil
}

func (p *AgentPool) Release(instance *AgentInstance) {
    p.mutex.Lock()
    defer p.mutex.Unlock()

    instance.InUse = false
    instance.LastUsed = time.Now()
}

O GetOrCreate é thread-safe via mutex. Múltiplas goroutines podem pedir instâncias simultaneamente sem race condition. Se a instância existe e não está em uso, é reutilizada. Se está em uso (outra goroutine pegou), uma nova é criada. O Release marca a instância como disponível para a próxima chamada.

O LastUsed é atualizado tanto no get quanto no release. Isso garante que instâncias ativas nunca são removidas pelo cleanup, mesmo que cada invocação individual seja curta.


Cleanup Automático de Instâncias Ociosas

Uma goroutine de background varre o pool periodicamente e remove instâncias que não são usadas há mais de maxIdle:

func (p *AgentPool) cleanup() {
    ticker := time.NewTicker(5 * time.Minute)
    defer ticker.Stop()

    for range ticker.C {
        p.mutex.Lock()
        for id, instance := range p.instances {
            if !instance.InUse && time.Since(instance.LastUsed) > p.maxIdle {
                delete(p.instances, id)
            }
        }
        p.mutex.Unlock()
    }
}

O ticker roda a cada 5 minutos. Só remove instâncias que não estão em uso E que estão ociosas há mais de 10 minutos. Instâncias em uso nunca são removidas, independente de quanto tempo estão ativas.

Esse cleanup evita memory leak em sessões longas. Se o sistema trabalha com 10 funções diferentes na primeira hora mas depois concentra carga em 2, as outras 8 são liberadas após 10 minutos de inatividade. O pool se adapta ao padrão de uso real.


Connection Pool com Workers

Para processamento paralelo, o sistema usa um pool de conexões com workers fixos:

type ConnectionPool struct {
    connections chan *Connection
    maxSize     int
    timeout     time.Duration
}

type Connection struct {
    ID        string
    CreatedAt time.Time
    LastUsed  time.Time
}

func NewConnectionPool(maxSize int, timeout time.Duration) *ConnectionPool {
    pool := &ConnectionPool{
        connections: make(chan *Connection, maxSize),
        maxSize:     maxSize,
        timeout:     timeout,
    }

    for i := 0; i < maxSize; i++ {
        pool.connections <- &Connection{
            ID:        fmt.Sprintf("conn_%d", i),
            CreatedAt: time.Now(),
        }
    }

    return pool
}

O channel buffered funciona como semáforo: no máximo maxSize invocações podem acontecer simultaneamente. Quando uma goroutine quer executar, ela pega uma conexão do channel bloqueia se todas estão em uso. Quando termina, devolve a conexão. Isso limita a concorrência sem precisar de contadores manuais.

O maxSize de 10 significa que no máximo 10 invocações paralelas acontecem ao mesmo tempo. Isso evita estourar o limite de concorrência da conta AWS e respeita os rate limits do serviço invocado.


Async Processor: Submit e Collect

O AsyncProcessor combina o connection pool com um padrão de submit/collect:

type AsyncProcessor struct {
    pool    *ConnectionPool
    workers int
}

type AsyncResult struct {
    Data  interface{}
    Error error
}

func (ap *AsyncProcessor) Submit(ctx context.Context, payload []byte) <-chan AsyncResult {
    resultChan := make(chan AsyncResult, 1)

    go func() {
        conn := <-ap.pool.connections
        defer func() { ap.pool.connections <- conn }()

        result, err := invokeLambda(ctx, payload)

        if err != nil {
            resultChan <- AsyncResult{Error: err}
        } else {
            resultChan <- AsyncResult{Data: result}
        }
        close(resultChan)
    }()

    return resultChan
}

O caller recebe um channel e decide quando coletar o resultado. Isso permite submeter múltiplas invocações e coletar os resultados depois, ou usar select para implementar timeout no lado do caller:

resultChan := processor.Submit(ctx, payload)

select {
case result := <-resultChan:
    if result.Error != nil {
        return nil, result.Error
    }
    return result.Data, nil
case <-ctx.Done():
    return nil, ctx.Err()
}

O select com ctx.Done() garante que se o context for cancelado, a goroutine não fica pendurada esperando uma Lambda que nunca vai responder. O resultado é descartado e o caller segue em frente.


Timeout por Chamada com Context

Cada invocação tem seu próprio timeout via context:

func (p *AgentPool) Execute(agentID string, payload []byte) ([]byte, error) {
    instance, err := p.GetOrCreate(agentID)
    if err != nil {
        return nil, err
    }
    defer p.Release(instance)

    ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
    defer cancel()

    result, err := invokeLambda(ctx, payload)

    if err != nil {
        if ctx.Err() == context.DeadlineExceeded {
            return nil, fmt.Errorf("lambda timeout after 120 seconds")
        }
        return nil, fmt.Errorf("lambda error: %v", err)
    }

    return result, nil
}

O context cancela a invocação se o timeout expirar. Sem isso, uma Lambda travada (esperando um banco lento, por exemplo) consumiria a conexão indefinidamente, esgotando o pool. Com o context, após 120 segundos a chamada é cancelada e a conexão é devolvida ao pool.

O tratamento de erro diferencia timeout de erro de execução. Cada caso tem mensagem específica para facilitar debug saber se foi timeout ou erro da função em si muda completamente o diagnóstico.


Considerações Finais

Pool de instâncias e processamento assíncrono são patterns fundamentais para qualquer sistema que faz chamadas externas com custo de inicialização. Em Go, a implementação é natural: channels como semáforos, goroutines como workers, e contexts como mecanismo de cancelamento. Sem framework, sem biblioteca externa.

O que importa entender é que cold start é um problema de gerenciamento, não de performance da função em si. A Lambda não ficou mais rápida. O que mudou é que o wrapper evita pagar o custo de inicialização repetidamente. É exatamente a mesma lógica do Provisioned Concurrency da AWS a diferença é que aqui você implementa no nível da aplicação, com controle total sobre o ciclo de vida das instâncias.

E o processamento assíncrono com channels é o pattern mais idiomático de Go que existe. Submit retorna um channel, o caller decide quando e como coletar. Pode coletar imediatamente (síncrono), depois (assíncrono), ou com select e timeout (resiliente). A mesma API serve todos os casos de uso.


Links indicativos: