Trilha de aprendizado · Nível 13 · Tutorial 12

Limitar operações assíncronas com Semaphore

Ao concluir, você será capaz de controlar quantas operações assíncronas acessam um recurso ao mesmo tempo e limitar a criação de tarefas sem confundir essas duas responsabilidades.

  • Nível: Avançado
  • Duração: 20 min
  • 9 passos
Limitar operações assíncronas com Semaphore

O que você vai percorrer

  1. Entender o limite de capacidade Diferencie capacidade compartilhada, exclusão mútua e controle de quantidade de operações por segundo. 2 min
  2. Compartilhar um semáforo entre as tarefas Crie uma capacidade única no coordenador e compartilhe essa mesma referência com todas as tarefas que usam o recurso limitado. 2 min
  3. Delimitar a operação que consome o recurso Escolha o trecho protegido pelo semáforo de acordo com o período em que o recurso está realmente ocupado. 2 min
  4. Preservar permissões diante de falhas Use o contexto assíncrono para devolver permissões sem esconder falhas ou cancelamentos. 2 min
  5. Escolher o alcance do timeout Posicione o timeout de acordo com a política: incluir a fila por capacidade ou limitar somente a operação já admitida. 3 min
  6. Separar operações ativas de tarefas existentes Entenda por que limitar o uso simultâneo de um recurso não limita, por si só, quantas tarefas foram criadas. 2 min
  7. Criar tarefas em lotes finitos Use lotes para limitar quantas tarefas de processamento existem por vez, mantendo um semáforo compartilhado para limitar as operações que realmente usam o recurso. 3 min
  8. Verificar o limite com execução controlada Use sinais e invariantes para comprovar o limite de simultaneidade sem depender de rede, arquivos ou durações medidas. 4 min
  9. Aplicar os dois limites e justificar a política Integre lotes, semáforo e timeout em um fluxo local e verificável, mantendo claras as responsabilidades de cada mecanismo. 3 min

O que você vai aprender

  • Aplicar um semáforo compartilhado para limitar operações em andamento.
  • Escolher o escopo da permissão conforme o recurso protegido.
  • Combinar o limite com uma política explícita de timeout.
  • Restringir a quantidade de tarefas criadas usando lotes finitos.
  • Verificar que o limite de operações simultâneas é respeitado inclusive em cenários de falha.

Antes de começar

  • Aplicar tempos de espera a operações assíncronas
  • Proteger estado compartilhado entre corrotinas
  • Executar tarefas com ThreadPoolExecutor

Passo 1 de 9

Entender o limite de capacidade

Diferencie capacidade compartilhada, exclusão mútua e controle de quantidade de operações por segundo.

Permissões para uma capacidade compartilhada

Até onde o recurso suporta

Um asyncio.Semaphore representa um conjunto de permissões. Sua capacidade define quantas operações podem usar o mesmo recurso ao mesmo tempo.

Se a capacidade é 3, até três tarefas podem estar na operação limitada simultaneamente. Uma quarta tarefa não começa essa operação ainda: ela aguarda cooperativamente até que uma permissão fique disponível.

O semáforo é adequado quando o recurso tem capacidade compartilhada, como um serviço externo que aceita algumas requisições simultâneas.

Capacidade 3 em ação

Observe que as tarefas aguardando ainda existem, mas não estão usando o recurso limitado.

Diagrama com cinco tarefas chegando a um recurso com três permissões; três tarefas estão dentro da área de uso do recurso e duas aguardam do lado de fora.

Com capacidade 3, no máximo três operações ficam admitidas simultaneamente.

Semáforo não é apenas um Lock

Exclusividade versus capacidade

Você já usou asyncio.Lock para proteger uma regra de estado compartilhado: ele permite uma tarefa por vez na seção crítica.

O semáforo generaliza a ideia para uma capacidade maior que um. Ele não diz que as operações são mutuamente exclusivas; diz que há um número limitado de vagas para elas.

Exemplo

Escolha pelo requisito

  • Atualizar um saldo compartilhado sem corrida: uma tarefa por vez → Lock.
  • Fazer até 4 chamadas simultâneas a um serviço: até quatro operações ativas → Semaphore com capacidade 4.
  • Aceitar no máximo 100 chamadas em cada segundo: é uma regra de taxa ao longo do tempo; capacidade simultânea, sozinha, não expressa esse requisito.

A simultaneidade não mede ritmo

Duas perguntas diferentes

Um limite de simultaneidade responde: “quantas operações estão em andamento agora?”

Ele não responde: “quantas operações começaram durante um segundo?”. Se operações curtas terminam rapidamente, permissões podem ser reutilizadas muitas vezes no mesmo intervalo. Portanto, um semáforo não garante um máximo de operações por segundo.

Associe o requisito ao controle

Relacione cada requisito ao controle que o atende diretamente.

Toque em um item e depois no par correspondente.

Passo 2 de 9

Compartilhar um semáforo entre as tarefas

Crie uma capacidade única no coordenador e compartilhe essa mesma referência com todas as tarefas que usam o recurso limitado.

Uma capacidade para o recurso compartilhado

Crie uma única instância

Se várias tarefas acessam o mesmo recurso com capacidade limitada, elas precisam disputar as permissões do mesmo asyncio.Semaphore.

Crie-o no coordenador assíncrono com uma capacidade inteira positiva. Depois, passe a referência para cada tarefa. Por exemplo, asyncio.Semaphore(2) permite que até duas operações estejam dentro do trecho limitado ao mesmo tempo.

Referência compartilhada

Todas as tarefas apontam para o mesmo conjunto de permissões.

Diagrama com um semáforo central de capacidade três ligado a cinco tarefas; três estão usando permissões e duas aguardam.

Um semáforo único coordena todas as tarefas sujeitas ao mesmo limite.

Dica

Critério prático

O escopo de criação acompanha o recurso: se as tarefas devem respeitar uma capacidade conjunta, entregue a elas a mesma instância do semáforo.

Admitir a operação com async with

Aguardar sem bloquear o laço

Na tarefa, use async with semaforo: ao redor da operação que será limitada. Se não houver permissão disponível, a tarefa aguarda cooperativamente; outras tarefas do laço podem continuar progredindo.

Ao sair normalmente do bloco, a permissão é devolvida e outra tarefa que aguarda pode ser admitida.

Função que recebe a capacidade compartilhada

A operação simulada só começa depois de obter uma permissão.

python
import asyncio

async def processar(item: str, semaforo: asyncio.Semaphore) -> str:
    async with semaforo:
        print(f"início: {item}")
        await asyncio.sleep(1)  # operação assíncrona simulada
        print(f"fim: {item}")
        return item.upper()

Script completo: limite conjunto de 2

Execute localmente

Salve o código como limite.py e execute python limite.py. Os itens podem terminar em ordem variável, mas nunca haverá mais de duas mensagens de início sem liberações correspondentes.

Um semáforo, várias tarefas

A instância é criada uma vez em main e passada para todas as tarefas do TaskGroup. O exemplo usa somente a biblioteca padrão.

python
import asyncio

async def processar(item: str, semaforo: asyncio.Semaphore) -> str:
    async with semaforo:
        print(f"início: {item}")
        await asyncio.sleep(1)
        print(f"fim: {item}")
        return item.upper()

async def main() -> None:
    itens = ["a", "b", "c", "d"]
    semaforo = asyncio.Semaphore(2)

    async with asyncio.TaskGroup() as grupo:
        tarefas = [
            grupo.create_task(processar(item, semaforo))
            for item in itens
        ]

    resultados = [tarefa.result() for tarefa in tarefas]
    print(resultados)

asyncio.run(main())

Atenção

Não crie um semáforo por tarefa

Isto não impõe um limite conjunto:

async def processar(item: str) -> str:
    semaforo = asyncio.Semaphore(2)  # instância independente
    async with semaforo:
        ...

Cada tarefa ganha seu próprio conjunto de duas permissões. Com várias tarefas, todas podem entrar ao mesmo tempo.

Complete o compartilhamento

Uma referência, um limite

Complete os dois espaços com o mesmo identificador para compartilhar a capacidade:

async def processar(item, ____):
    async with ____:
        await asyncio.sleep(1)

async def main():
    semaforo = asyncio.Semaphore(2)
    async with asyncio.TaskGroup() as grupo:
        grupo.create_task(processar("a", semaforo))

Passo 3 de 9

Delimitar a operação que consome o recurso

Escolha o trecho protegido pelo semáforo de acordo com o período em que o recurso está realmente ocupado.

Ocupação real do recurso

Permissão só enquanto há consumo

O semáforo deve ser adquirido imediatamente antes de uma tarefa começar a usar o recurso limitado e liberado somente depois que esse uso terminou de verdade. Preparar dados antes e processar o resultado depois não precisa consumir uma permissão, se essas etapas forem independentes do recurso.

Escopo correto da permissão

A faixa destacada representa o trecho que deve ficar dentro de async with semaforo.

Diagrama de uma tarefa em cinco etapas: preparação, espera pela permissão, uso do recurso, encerramento do uso e pós-processamento. Apenas uso e encerramento estão destacados como trecho protegido.

Proteja o uso efetivo e a finalização que libera o recurso; deixe fora as etapas independentes.

Dica

Pergunta para definir o escopo

Pergunte: “Se esta linha ainda estiver executando, o recurso continua ocupado?” Se a resposta for sim, ela pertence ao bloco protegido — inclusive um await de encerramento necessário.

O que fica dentro do bloco

Preparar fora, usar e encerrar dentro

Neste exemplo, enviar() e fechar() representam o uso de um recurso com capacidade limitada, como uma conexão ou uma API externa.

python
import asyncio

async def preparar(item: str) -> str:
    return item.upper()

async def enviar(dado: str) -> str:
    await asyncio.sleep(0.2)  # recurso está ocupado
    return f"resposta para {dado}"

async def fechar() -> None:
    await asyncio.sleep(0)  # encerramento necessário do uso

async def pos_processar(resposta: str) -> str:
    return resposta.replace("resposta", "resultado")

async def processar(item: str, semaforo: asyncio.Semaphore) -> str:
    dado = await preparar(item)  # independente: fora do limite

    async with semaforo:
        resposta = await enviar(dado)  # recurso ocupado
        await fechar()                 # ainda ocupa até encerrar

    return await pos_processar(resposta)  # independente: fora do limite

Não limite apenas o agendamento

Este padrão não protege o consumo do recurso:

async with semaforo:
    tarefa = asyncio.create_task(enviar(dado))

return await tarefa

O bloco termina logo após criar a tarefa. Quando enviar() começa a executar, a permissão já foi devolvida; assim, várias tarefas podem usar o recurso ao mesmo tempo. O contexto deve envolver o await da operação que consome o recurso.

Atenção

Escopo curto demais quebra o limite

Proteger create_task(...) limita apenas a criação daquela tarefa durante um instante. Não limita a atividade que ela executará depois, fora do contexto.

Escolha o menor escopo suficiente

Qual bloco deve usar o semáforo?

preparar() só transforma dados locais. enviar() usa uma conexão limitada. fechar() conclui o uso dessa conexão. formatar() trabalha apenas com o resultado já obtido. Qual é o menor escopo correto?

Justifique a decisão

Em suas palavras, por que envolver apenas create_task(enviar(dado)) com async with semaforo não limita as operações de envio?

Escreva pelo menos 80 caracteres (0/80).

Passo 4 de 9

Preservar permissões diante de falhas

Use o contexto assíncrono para devolver permissões sem esconder falhas ou cancelamentos.

A permissão retorna na saída do contexto

Limpeza automática após adquirir

Depois que uma tarefa entra em async with semaforo:, ela passa a possuir uma permissão. Ao sair desse bloco, a permissão é devolvida automaticamente — tanto ao concluir normalmente quanto se a operação lançar uma exceção ou receber cancelamento.

Esse comportamento mantém a capacidade disponível para as outras tarefas. A exceção ou asyncio.CancelledError continua se propagando: o semáforo cuida da permissão, não do tratamento do erro.

Três saídas, mesma devolução

A devolução acontece somente para quem adquiriu a permissão.

Diagrama de um semáforo com duas permissões: três tarefas entram no contexto protegido e saem por sucesso, exceção e cancelamento; nos três casos uma permissão retorna ao conjunto compartilhado. Uma quarta tarefa é cancelada antes de entrar e não devolve nada.

Sucesso, falha e cancelamento após a aquisição devolvem uma permissão. Cancelamento enquanto aguarda não cria uma permissão para devolver.

Dica

Regra prática

Prefira async with semaforo: em vez de chamar acquire() e release() manualmente. O contexto associa a devolução exatamente a uma aquisição bem-sucedida.

Não libere duas vezes nem suprima o cancelamento

Operação cooperativa protegida

A operação simulada pode falhar ou ser cancelada em um await.

python
import asyncio

async def enviar(item: str, semaforo: asyncio.Semaphore) -> str:
    async with semaforo:
        # A permissão já foi adquirida aqui.
        await asyncio.sleep(0.1)  # representa uma operação cooperativa
        if item == "invalido":
            raise ValueError("item recusado")
        return f"enviado: {item}"

# Se sleep, a validação ou outra etapa lançar exceção,
# ou se a tarefa for cancelada, o async with devolve a permissão.
# Não capture CancelledError para ignorá-lo; deixe-o se propagar.

Atenção

Dois erros que quebram a contagem

Não chame semaforo.release() dentro ou depois de um bloco async with semaforo:: isso devolve duas permissões para uma única aquisição e pode permitir operações acima do limite.

Também não devolva manualmente uma permissão se a tarefa foi cancelada enquanto ainda aguardava entrar no async with: ela nunca a adquiriu.

Devolver a permissão não encerra, por si só, trabalho externo que já esteja em andamento. Nos exemplos cooperativos, o cancelamento é percebido em pontos de espera; integrações externas precisam ter sua própria política de interrupção e limpeza.

Verifique sua decisão

Cancelamento durante a espera

Uma tarefa é cancelada enquanto está aguardando entrar em async with semaforo:. Ela deve chamar semaforo.release() em um finally para compensar o cancelamento.

Passo 5 de 9

Escolher o alcance do timeout

Posicione o timeout de acordo com a política: incluir a fila por capacidade ou limitar somente a operação já admitida.

Duas políticas de prazo

O lugar do timeout define o orçamento

Um semáforo pode fazer uma tarefa esperar antes de usar o recurso. Decida se essa espera faz parte do prazo:

  • Prazo total: a tarefa precisa obter capacidade e concluir a operação dentro do orçamento.
  • Prazo da operação: a tarefa pode aguardar capacidade pelo tempo necessário; depois de admitida, a operação recebe seu próprio prazo.

Não há uma posição universalmente correta: o aninhamento deve expressar o requisito.

Compare os dois aninhamentos

A borda colorida representa o trecho coberto pelo timeout. A permissão é ocupada somente após a entrada no semáforo.

Diagrama com duas linhas do tempo. Na primeira, o timeout cobre a espera antes de um portão de semáforo e a operação após o portão. Na segunda, a espera acontece antes do portão e o timeout cobre apenas a operação depois dele.

Timeout externo inclui a espera; timeout interno começa após a admissão.

Timeout externo: fila e operação

Inclua a espera quando ela também tem prazo

Coloque asyncio.timeout fora de async with semaforo quando a tarefa não pode ficar aguardando capacidade indefinidamente. O orçamento começa antes da aquisição e continua durante a operação admitida.

Orçamento total por tarefa

Capture TimeoutError depois de sair do contexto de timeout.

python
import asyncio

async def usar_recurso(semaforo: asyncio.Semaphore, item: str) -> None:
    try:
        async with asyncio.timeout(5):
            async with semaforo:
                await operacao_externa(item)
    except TimeoutError:
        print(f"{item}: prazo total esgotado")

async def operacao_externa(item: str) -> None:
    await asyncio.sleep(1)
    print(f"{item}: concluído")

Dica

Permissão devolvida automaticamente

Se o prazo se esgotar depois da aquisição, o cancelamento sai do bloco async with semaforo. A permissão é devolvida na saída desse contexto; não chame release() manualmente.

Timeout interno: somente após a admissão

Espere capacidade sem consumir o prazo da operação

Coloque asyncio.timeout dentro de async with semaforo quando o requisito é limitar apenas o uso efetivo do recurso. Nesse desenho, a espera pela permissão não tem prazo definido por esse timeout.

Prazo apenas da operação admitida

A tarefa primeiro espera a permissão. Só então começa o orçamento de 5 segundos.

python
import asyncio

async def usar_recurso(semaforo: asyncio.Semaphore, item: str) -> None:
    async with semaforo:
        try:
            async with asyncio.timeout(5):
                await operacao_externa(item)
        except TimeoutError:
            print(f"{item}: operação excedeu o prazo")

async def operacao_externa(item: str) -> None:
    await asyncio.sleep(1)
    print(f"{item}: concluído")

Atenção

Não confunda espera com operação

O segundo código não impõe limite de espera pela capacidade. Se isso violar o requisito, use o timeout externo ou outra política explícita para a fila.

Escolha o aninhamento pela política

Associe cada requisito ao desenho

Relacione o requisito ao posicionamento adequado de asyncio.timeout.

Toque em um item e depois no par correspondente.

Passo 6 de 9

Separar operações ativas de tarefas existentes

Entenda por que limitar o uso simultâneo de um recurso não limita, por si só, quantas tarefas foram criadas.

Três quantidades diferentes

O que o semáforo realmente limita

Em um fluxo assíncrono, não confunda estas quantidades:

  • Tarefas criadas: corrotinas agendadas para processar as entradas.
  • Tarefas aguardando admissão: tarefas que chegaram ao async with semaforo, mas ainda não obtiveram permissão.
  • Operações ativas: tarefas que já obtiveram uma permissão e estão no trecho que usa o recurso.

O asyncio.Semaphore limita apenas as operações ativas dentro do escopo protegido. As demais tarefas podem continuar existindo e aguardando.

Tarefas existentes versus operações ativas

Com capacidade 3, apenas três operações usam o recurso de uma vez. Isso não impede que muitas outras tarefas já tenham sido criadas e estejam esperando.

Diagrama mostrando doze tarefas criadas, seis aguardando antes de um portão de semáforo e três operações ativas depois do portão, usando um recurso compartilhado.

A capacidade controla quem entra no trecho protegido; ela não determina quantas tarefas existem.

Criar tudo ainda cria trabalho pendente

Exemplo

Um TaskGroup não impõe um teto de tarefas

import asyncio

async def usar_recurso(item: int, semaforo: asyncio.Semaphore) -> None:
    async with semaforo:
        await asyncio.sleep(1)  # operação limitada

async def main() -> None:
    itens = range(1_000)
    semaforo = asyncio.Semaphore(3)

    async with asyncio.TaskGroup() as grupo:
        for item in itens:
            grupo.create_task(usar_recurso(item, semaforo))

asyncio.run(main())

Aqui, o semáforo permite no máximo 3 operações ativas no async with. Porém, o laço cria até 1.000 tarefas de processamento antes de o TaskGroup terminar. Muitas podem ficar aguardando uma permissão.

O TaskGroup organiza o ciclo de vida: ele espera as tarefas e coordena falhas. Ele não estabelece, sozinho, um máximo para a quantidade de tarefas criadas.

Dica

Duas decisões independentes

Reduzir a capacidade de 3 para 1 reduz operações simultâneas, mas não muda o for: ele ainda tenta criar uma tarefa para cada entrada. Se também for necessário conter tarefas pendentes, é preciso adotar uma política adicional para admitir novas tarefas.

Verifique a distinção

O que fica limitado?

Um programa cria 500 tarefas em um TaskGroup. Todas usam a mesma instância de asyncio.Semaphore(4) ao redor da chamada que acessa um serviço externo. Qual afirmação é correta?

Passo 7 de 9

Criar tarefas em lotes finitos

Use lotes para limitar quantas tarefas de processamento existem por vez, mantendo um semáforo compartilhado para limitar as operações que realmente usam o recurso.

Dois limites, duas responsabilidades

Lote limita tarefas; semáforo limita uso ativo

Um semáforo não impede que você crie muitas tarefas: elas podem ficar aguardando uma permissão. Para limitar também as tarefas de processamento desse fluxo, forme um lote finito, crie somente as tarefas dele e espere o TaskGroup encerrar antes de iniciar o próximo.

O tamanho do lote limita as tarefas de processamento criadas em cada rodada. A capacidade do semáforo limita as operações que estão usando o recurso ao mesmo tempo. Portanto, as operações ativas ficam limitadas por min(capacidade, tamanho_do_lote).

Fluxo de lotes com capacidade compartilhada

Cada lote termina antes que o próximo comece; o mesmo semáforo continua valendo para todos eles.

Diagrama com três lotes sequenciais. Em cada lote há até três tarefas, das quais no máximo duas atravessam simultaneamente um portão de recurso compartilhado.

Com lote de 3 e semáforo de capacidade 2, há no máximo 3 tarefas de processamento no lote e no máximo 2 operações usando o recurso.

Um TaskGroup por lote

Semáforo fora do laço

itertools.batched está disponível no Python 3.12 ou superior. Crie o semáforo uma única vez, antes do laço de lotes. Assim, todas as operações usam a mesma capacidade, em vez de cada lote ou tarefa ganhar um limite independente.

Script completo para executar localmente

O código processa entradas locais simuladas. Os resultados de um lote são lidos após o TaskGroup e usados imediatamente; as referências às tarefas daquele lote não são guardadas para os próximos.

python
import asyncio
from itertools import batched


async def processar_item(item: str, semaforo: asyncio.Semaphore) -> str:
    # Preparação independente do recurso limitado.
    item_normalizado = item.strip()

    # Somente o uso simulado do recurso consome uma permissão.
    async with semaforo:
        await asyncio.sleep(0.1)
        resposta = item_normalizado.upper()

    # Pós-processamento independente também fica fora do semáforo.
    return f"processado: {resposta}"


async def processar_em_lotes(entradas: list[str]) -> None:
    tamanho_lote = 3
    capacidade = 2
    semaforo = asyncio.Semaphore(capacidade)

    for numero_lote, lote in enumerate(batched(entradas, tamanho_lote), start=1):
        tarefas: list[asyncio.Task[str]] = []

        async with asyncio.TaskGroup() as grupo:
            for item in lote:
                tarefas.append(grupo.create_task(processar_item(item, semaforo)))

        # A saída bem-sucedida do grupo garante que estas tarefas terminaram.
        resultados_lote = [tarefa.result() for tarefa in tarefas]
        print(f"lote {numero_lote}: {resultados_lote}")


async def main() -> None:
    entradas = ["ana", "bia", "caio", "davi", "elis"]
    await processar_em_lotes(entradas)


asyncio.run(main())

O que acontece no fim de cada lote

Encerramento é a barreira entre rodadas

A saída do async with asyncio.TaskGroup() espera as tarefas do lote terminarem. Só então o laço pode formar o próximo lote. Se uma tarefa tiver uma falha não tratada, o TaskGroup encerra conforme sua política de falhas e essa falha sai do bloco: os lotes seguintes não são criados.

O último lote pode ter menos itens que o tamanho configurado. Nesse caso, parte da capacidade do semáforo pode ficar ociosa. Isso é esperado: não há tarefas suficientes naquele lote para ocupá-la.

Dica

Limite de tarefas não é limite global de memória

Lotes evitam criar uma tarefa de processamento para cada entrada de uma vez. Ainda assim, a aplicação pode reter as entradas originais, resultados acumulados ou outros objetos. Neste exemplo, cada resultado é consolidado e exibido por lote, sem guardar referências às tarefas anteriores.

Ordene o ciclo de um lote

Sequência de processamento

Coloque as etapas na ordem em que ocorrem para um lote.

  1. Formar o próximo lote finito com `batched`.
  2. Ler e consolidar os resultados do lote; então seguir para a próxima rodada.
  3. Sair do `TaskGroup`, aguardando o encerramento das tarefas do lote.
  4. Cada tarefa aguarda uma permissão e executa sua operação protegida.
  5. Criar, no `TaskGroup`, uma tarefa para cada item desse lote.

Passo 8 de 9

Verificar o limite com execução controlada

Use sinais e invariantes para comprovar o limite de simultaneidade sem depender de rede, arquivos ou durações medidas.

Transforme a operação em um cenário controlável

Controle estados, não tempos

Para verificar um semáforo, substitua a operação externa por uma corrotina que fica parada em um asyncio.Event. Assim, você sabe exatamente quando operações admitidas continuam ocupando as permissões e quando serão liberadas.

Dentro do async with semaforo, conte ativas, atualize o maior valor observado em pico e faça o decremento em finally. Como essas atualizações não têm await entre leitura e escrita, elas não são intercaladas por outra tarefa no mesmo laço de eventos.

O teste deve provar três invariantes: pico <= capacidade, ocupação efetiva da capacidade quando há tarefas suficientes, e ativas == 0 depois do encerramento.

Estados que o teste observa

O sinal liberar mantém as operações admitidas no recurso até que o teste decida prosseguir.

Diagrama mostrando três tarefas diante de um semáforo com capacidade dois: duas operações estão dentro da área protegida e paradas em um sinal de evento; uma terceira aguarda fora. Um contador de ativas marca dois e um marcador de pico também marca dois.

A confirmação de capacidade cheia evita que um teste sequencial passe por engano.

Execute um script de verificação local

Cenários cobertos

Copie o script para um arquivo, por exemplo verificar_semaforo.py, e execute python verificar_semaforo.py. Ele usa somente a biblioteca padrão e verifica sucesso, exceção, cancelamento durante a espera e cancelamento durante uma operação admitida. Após cada caso de falha ou cancelamento, uma nova ocupação completa confirma que nenhuma permissão foi perdida.

verificar_semaforo.py

python
import asyncio
from dataclasses import dataclass, field

CAPACIDADE = 2


@dataclass
class Sonda:
    capacidade: int
    semaforo: asyncio.Semaphore = field(init=False)
    liberar: asyncio.Event = field(default_factory=asyncio.Event)
    capacidade_cheia: asyncio.Event = field(default_factory=asyncio.Event)
    ativas: int = 0
    pico: int = 0

    def __post_init__(self) -> None:
        self.semaforo = asyncio.Semaphore(self.capacidade)

    def preparar_onda(self) -> None:
        self.liberar = asyncio.Event()
        self.capacidade_cheia = asyncio.Event()
        self.ativas = 0
        self.pico = 0

    async def operacao(
        self,
        *,
        falhar: bool = False,
        iniciou_tentativa: asyncio.Event | None = None,
    ) -> None:
        # O sinal é emitido antes de tentar adquirir uma permissão.
        if iniciou_tentativa is not None:
            iniciou_tentativa.set()

        async with self.semaforo:
            self.ativas += 1
            self.pico = max(self.pico, self.ativas)
            if self.ativas == self.capacidade:
                self.capacidade_cheia.set()

            try:
                await self.liberar.wait()
                if falhar:
                    raise RuntimeError("falha simulada")
            finally:
                self.ativas -= 1


async def esperar_capacidade_cheia(sonda: Sonda) -> None:
    # Este timeout é apenas uma proteção contra teste travado.
    try:
        async with asyncio.timeout(1):
            await sonda.capacidade_cheia.wait()
    except TimeoutError as erro:
        raise AssertionError("A capacidade não foi ocupada no teste") from erro


def verificar_encerramento(sonda: Sonda) -> None:
    assert sonda.pico <= sonda.capacidade
    assert sonda.pico == sonda.capacidade
    assert sonda.ativas == 0


async def onda_com_sucesso(sonda: Sonda) -> None:
    sonda.preparar_onda()
    tarefas = [
        asyncio.create_task(sonda.operacao())
        for _ in range(sonda.capacidade)
    ]
    await esperar_capacidade_cheia(sonda)
    sonda.liberar.set()
    await asyncio.gather(*tarefas)
    verificar_encerramento(sonda)


async def onda_com_falha(sonda: Sonda) -> None:
    sonda.preparar_onda()
    tarefas = [
        asyncio.create_task(sonda.operacao(falhar=(indice == 0)))
        for indice in range(sonda.capacidade)
    ]
    await esperar_capacidade_cheia(sonda)
    sonda.liberar.set()
    resultados = await asyncio.gather(*tarefas, return_exceptions=True)
    assert any(isinstance(resultado, RuntimeError) for resultado in resultados)
    verificar_encerramento(sonda)


async def cancelamento_durante_espera(sonda: Sonda) -> None:
    sonda.preparar_onda()
    ocupantes = [
        asyncio.create_task(sonda.operacao())
        for _ in range(sonda.capacidade)
    ]
    await esperar_capacidade_cheia(sonda)

    tentativa_iniciada = asyncio.Event()
    aguardando = asyncio.create_task(
        sonda.operacao(iniciou_tentativa=tentativa_iniciada)
    )
    await tentativa_iniciada.wait()
    aguardando.cancel()
    try:
        await aguardando
    except asyncio.CancelledError:
        pass
    else:
        raise AssertionError("A tarefa que aguardava deveria ser cancelada")

    sonda.liberar.set()
    await asyncio.gather(*ocupantes)
    verificar_encerramento(sonda)


async def cancelamento_durante_operacao(sonda: Sonda) -> None:
    sonda.preparar_onda()
    tarefas = [
        asyncio.create_task(sonda.operacao())
        for _ in range(sonda.capacidade)
    ]
    await esperar_capacidade_cheia(sonda)

    tarefas[0].cancel()
    try:
        await tarefas[0]
    except asyncio.CancelledError:
        pass
    else:
        raise AssertionError("A operação admitida deveria ser cancelada")

    sonda.liberar.set()
    await asyncio.gather(*tarefas[1:])
    verificar_encerramento(sonda)


async def main() -> None:
    sonda = Sonda(CAPACIDADE)

    await onda_com_sucesso(sonda)
    await onda_com_falha(sonda)
    await onda_com_sucesso(sonda)  # confirma recuperação após falha

    await cancelamento_durante_espera(sonda)
    await onda_com_sucesso(sonda)  # confirma que espera cancelada não devolveu a mais

    await cancelamento_durante_operacao(sonda)
    await onda_com_sucesso(sonda)  # confirma devolução após cancelamento admitido

    print("Todas as invariantes foram verificadas.")


if __name__ == "__main__":
    asyncio.run(main())

Leia o que as asserções realmente demonstram

Evidência sem cronômetro

capacidade_cheia.wait() só é liberado quando ativas == capacidade. Portanto, cada onda de sucesso demonstra que havia operações suficientes para preencher todas as permissões; não aprovaria uma execução indevidamente sequencial.

Depois que as tarefas terminam, pico <= capacidade demonstra que o limite nunca foi ultrapassado, e ativas == 0 demonstra que o trecho protegido foi encerrado. As ondas posteriores são a evidência adicional de que exceções e cancelamentos não deixaram permissões retidas.

Atenção

O que não usar como prova

Não use sleep, uma ordem supostamente previsível de admissão, atributos privados do semáforo ou uma duração observada como prova do limite. O timeout de 1 segundo no script serve somente para impedir que um defeito deixe o teste esperando para sempre; ele não mede desempenho nem estabelece uma ordem entre tarefas.

Relate a execução

Quais invariantes passaram?

Execute o script no seu computador. Relate a mensagem final e explique quais asserções evidenciam: capacidade ocupada, respeito ao limite, encerramento das operações e recuperação após falha ou cancelamento.

Escreva pelo menos 120 caracteres (0/120).

Passo 9 de 9

Aplicar os dois limites e justificar a política

Integre lotes, semáforo e timeout em um fluxo local e verificável, mantendo claras as responsabilidades de cada mecanismo.

Defina uma política com três decisões

Dois limites, duas configurações

Use configurações independentes: TAMANHO_LOTE limita quantas tarefas de processamento este fluxo cria por vez; CAPACIDADE limita quantas dessas tarefas podem usar o recurso simultaneamente. Neste cenário, o timeout fica fora do async with semaforo: o orçamento inclui tanto a espera por capacidade quanto a operação admitida.

A permissão começa imediatamente antes da operação que usa o recurso e termina quando essa operação retorna ou falha. Preparação de dados e consolidação de resultados ficam fora desse trecho.

Responsabilidades separadas

A ordem visual mostra onde cada política atua.

Diagrama de fluxo com lotes de quatro tarefas, um semáforo com duas permissões protegendo somente a operação de recurso e um contorno de timeout envolvendo a espera e a operação.

O lote limita tarefas existentes; o semáforo limita operações ativas; o timeout define o orçamento do escopo escolhido.

Dica

Escolha explícita

Se o requisito fosse “depois de admitida, a operação tem até 2 segundos”, o asyncio.timeout ficaria dentro do semáforo. Assim, a espera por uma permissão não teria prazo definido por esse contexto.

Execute e verifique localmente

Script completo

Salve o código como limites.py e execute python limites.py em Python 3.12 ou superior. Ele usa apenas a biblioteca padrão. Os Events tornam a verificação controlada: não dependem de uma duração ou ordem específica.

limites.py

python
import asyncio
from itertools import batched

CAPACIDADE = 2       # Máximo de operações usando o recurso.
TAMANHO_LOTE = 3     # Máximo de tarefas de processamento por lote.


async def operacao_simulada(item: str) -> str:
    """Substitua pelo uso real do recurso limitado."""
    await asyncio.sleep(0)
    if item == "falha":
        raise RuntimeError("falha simulada")
    return item.upper()


async def processar(item: str, semaforo: asyncio.Semaphore, operacao) -> str:
    try:
        # Política: os 2 segundos incluem esperar capacidade e usar o recurso.
        async with asyncio.timeout(2):
            async with semaforo:
                return await operacao(item)
    except TimeoutError:
        return f"timeout:{item}"
    except RuntimeError as erro:
        return f"erro:{item}:{erro}"


async def processar_em_lotes(itens: list[str]) -> list[str]:
    semaforo = asyncio.Semaphore(CAPACIDADE)  # Uma instância compartilhada.
    resultados: list[str] = []

    for lote in batched(itens, TAMANHO_LOTE):
        # As referências das tarefas vivem somente durante este lote.
        async with asyncio.TaskGroup() as grupo:
            tarefas = [
                grupo.create_task(processar(item, semaforo, operacao_simulada))
                for item in lote
            ]
        resultados.extend(tarefa.result() for tarefa in tarefas)

    return resultados


class RecursoControlado:
    def __init__(self, capacidade: int) -> None:
        self.capacidade = capacidade
        self.ativas = 0
        self.pico = 0
        self.capacidade_ocupada = asyncio.Event()
        self.liberar = asyncio.Event()

    async def usar(self, item: str) -> str:
        self.ativas += 1
        self.pico = max(self.pico, self.ativas)
        if self.ativas == self.capacidade:
            self.capacidade_ocupada.set()
        try:
            if item == "falha":
                raise RuntimeError("falha controlada")
            await self.liberar.wait()
            return item.upper()
        finally:
            self.ativas -= 1


async def verificar_limites() -> None:
    # Sucesso: há tarefas suficientes para ocupar toda a capacidade.
    semaforo = asyncio.Semaphore(CAPACIDADE)
    recurso = RecursoControlado(CAPACIDADE)
    tarefas = [
        asyncio.create_task(processar(str(indice), semaforo, recurso.usar))
        for indice in range(CAPACIDADE)
    ]
    await asyncio.wait_for(recurso.capacidade_ocupada.wait(), timeout=1)
    assert recurso.pico == CAPACIDADE
    assert recurso.ativas == CAPACIDADE
    recurso.liberar.set()
    assert await asyncio.gather(*tarefas) == ["0", "1"]
    assert recurso.ativas == 0

    # Falha após admissão: async with devolve a permissão automaticamente.
    recurso_falha = RecursoControlado(1)
    resultado = await processar("falha", semaforo, recurso_falha.usar)
    assert resultado.startswith("erro:falha:")
    async with semaforo:  # A permissão continua disponível.
        pass

    # Cancelamento enquanto espera: não há acquire bem-sucedido para devolver.
    recurso_espera = RecursoControlado(1)
    ocupante = asyncio.create_task(processar("ocupante", semaforo, recurso_espera.usar))
    await asyncio.wait_for(recurso_espera.capacidade_ocupada.wait(), timeout=1)
    chegou_para_esperar = asyncio.Event()

    async def aguardar_permissao() -> None:
        chegou_para_esperar.set()
        async with semaforo:
            raise AssertionError("não deveria adquirir durante este cenário")

    aguardando = asyncio.create_task(aguardar_permissao())
    await chegou_para_esperar.wait()
    aguardando.cancel()
    try:
        await aguardando
    except asyncio.CancelledError:
        pass
    recurso_espera.liberar.set()
    await ocupante

    # Cancelamento durante a operação também libera a permissão.
    recurso_cancelado = RecursoControlado(1)
    em_operacao = asyncio.create_task(processar("cancelar", semaforo, recurso_cancelado.usar))
    await asyncio.wait_for(recurso_cancelado.capacidade_ocupada.wait(), timeout=1)
    em_operacao.cancel()
    try:
        await em_operacao
    except asyncio.CancelledError:
        pass
    async with semaforo:
        pass


async def main() -> None:
    resultados = await processar_em_lotes(["ana", "bia", "falha", "davi"])
    print(resultados)
    await verificar_limites()
    print("Verificações concluídas.")


if __name__ == "__main__":
    asyncio.run(main())

Atenção

O que não fazer

Não chame semaforo.release() manualmente dentro de um bloco async with semaforo. Depois de uma aquisição bem-sucedida, o contexto já devolve a permissão em saídas normais, falhas e cancelamentos. Uma devolução extra corrompe o limite.

Justifique e conclua

Explique a política aplicada

Após executar o script, relate: (1) o que TAMANHO_LOTE limita; (2) o que CAPACIDADE limita; (3) por que o timeout está fora do semáforo; e (4) quais asserções indicam que não houve perda de permissões.

Escreva pelo menos 180 caracteres (0/180).

Resumo

Síntese operacional

  • Compartilhe uma única instância de asyncio.Semaphore entre todas as tarefas que usam o mesmo recurso limitado.
  • Delimite a permissão ao uso efetivo do recurso, incluindo a finalização necessária para liberá-lo.
  • Use lotes finitos e um TaskGroup por lote para limitar tarefas de processamento; o semáforo, sozinho, não limita tarefas aguardando.
  • Posicione o timeout conforme o orçamento deve incluir ou excluir a espera pela capacidade.
  • Verifique o pico de operações e a recuperação após falhas e cancelamentos com sinais e estados controlados.
  • A solução não garante uma taxa de operações por segundo, um prazo rígido de execução nem um limite global de memória.

Tutorial concluído

Parabéns! Você concluiu: Limitar operações assíncronas com Semaphore

Você integrou limites de tarefas, operações simultâneas e tempo de espera em uma política explícita e verificável.

Baixe o Aplicativo agora para ter acesso a + de 5000 cursos gratuitos, exercícios, certificado e muito conteúdo sem pagar nada!

  • Cursos online 100% gratuitos do início ao fim

    Milhares de cursos online em vídeo, ebooks e áudiobooks.

  • Mais de 60 mil exercícios gratuitos

    Para testar seus conhecimentos no decorrer dos cursos online

  • Certificado Digital gratuito válido em todo o Brasil

    Gerado diretamente na galeria de fotos do seu celular e enviado ao seu e-mail

Aplicativo Cursa na tela de ebook, na tela de curso em vídeo e na tela de exercícios do curso, mais o certificado de conclusão de curso