Gerenciamento de Concorrência em Python com Primitivas de Sincronização

Introdução às Primitivas de Sincronização

As primitivas de sincronização são mecanismos fundamentais em sistemas operacionais e programação concorrente, desenhadas para coordenar a sequência de operações e o acesso a recursos compartilhados entre múltiplos threads ou processos. Seu objetivo primordial é prevenir problemas comuns da concorrência, como condições de corrida, impasses (deadlocks) e inconsistência de dados.

A seguir, listamos algumas das primitivas de sincronização mais comuns:

  1. Fechos (Locks): Um fecho é uma primitiva básica que controla o acesso a um recurso compartilhado. Somente um thread ou processo pode possuir o fecho em um dado momento. Outros que tentarem adquiri-lo deverão esperar. Fechos são cruciais para evitar acesso simultâneo e garantir a integridade dos dados.
  2. Variáveis de Condição (Condition Variables): Mais avançadas, variáveis de condição facilitam a coordenação complexa entre threads. Elas permitem que um thread aguarde até que uma determinada condição seja verdadeira, sendo então notificado por outro thread.
  3. Semáforos (Semaphores): Semáforos são primitivas de sincronização baseadas em contagem, usadas para limitar o número de threads ou processos que podem acessar simultaneamente um recurso. Um contador interno é decrementado ao adquirir e incrementado ao liberar o semáforo.
  4. Mutexes (Mutual Exclusion): Semelhantes a fechos, mutexes garantem acesso exclusivo a uma seção crítica de código. São frequentemente utilizados em nível de sistema operacional para assegurar que apenas uma entidade possa modificar um dado recurso por vez.
  5. Barreiras (Barriers): Uma barreira é uma primitiva que força múltiplos threads ou processos a esperarem em um ponto específico de execução até que todos os participantes tenham chegado. Após todos alcançarem a barreira, eles podem prosseguir simultaneamente.

Essas ferramentas são a base para gerenciar a coordenação e as condições de corrida em programas concorrentes, assegurando sua execução correta.

Explorando Fechos (Locks)

Fechos são usados para controlar o acesso a recursos compartilhados, garantindo que apenas um thread ou processo o acesse por vez. Um fecho pode estar em dois estados: travado (locked) ou destravado (unlocked).

Para acessar um recurso protegido por um fecho, um thread deve primeiro tentar adquiri-lo. A aquisição só é bem-sucedida se o fecho estiver destravado. Se o fecho já estiver sendo mantido por outra entidade, o thread solicitante será bloqueado até que o fecho seja liberado.

As operações fundamentais de um fecho são:

  1. adquirir(blocking=True, timeout=None): Tenta obter o fecho. Se disponível, o fecho é travado e o método retorna True. Se o fecho estiver ocupado, o comportamento depende dos parâmetros blocking e timeout, podendo bloquear o thread até que o fecho seja liberado ou retornar imediatamente.
  2. liberar(): Libera o fecho, mudando seu estado para destravado. Se houver threads esperando, um deles será acordado e adquirirá o fecho.

A principal aplicação de fechos é em cenários de concorrência (multithreading ou multiprocessing) para proteger o acesso a recursos compartilhados, prevenindo condições de corrida e inconsistências de dados. No entanto, o uso de fechos inevitavelmente introduz uma penalidade de desempenho, aumentando o tempo de execução devido à serialização do acesso.

Categorias de Fechos na Programação Concorrente

Na programação concorrente, diversas variações de fechos atendem a necessidades específicas:

  1. Fechos de Exclusão Mútua (Mutex): O tipo mais básico, assegura que apenas um thread possa manter o fecho por vez. Impede que múltiplos threads acessem um recurso compartilhado simultaneamente. Em Python, threading.Lock e multiprocessing.Lock implementam mutexes.
  2. Fechos Recursivos (RLock): Permitem que o mesmo thread adquira o fecho múltiplas vezes sem se auto-bloquear, prevenindo impasses em código recursivo ou aninhado. Cada aquisição requer uma liberação correspondente. threading.RLock e multiprocessing.RLock são exemplos em Python.
  3. Fechos de Variável de Condição (Condition Lock): Combinam um fecho com uma variável de condição para coordenação complexa. Permitem que threads esperem por uma condição específica e sejam notificados quando ela for atendida. Implementado por threading.Condition e multiprocessing.Condition.
  4. Semáforos (Semaphore): Um fecho contador que limita o número de threads que podem acessar um recurso simultaneamente. Mantém um contador que é decrementado na aquisição e incrementado na liberação. threading.Semaphore e multiprocessing.Semaphore são as implementações em Python.
  5. Fechos de Evento (Event Lock): Uma primitiva de sincronização para comunicação entre threads. Um thread pode sinalizar um evento, e outros threads podem esperar por ele. threading.Event e multiprocessing.Event oferecem essa funcionalidade.

A escolha do tipo de fecho depende da necessidade específica do problema de concorrência, desde a simples exclusão mútua até a coordenação complexa de threads.

Fechos de Exclusão Mútua em Ação

Fechos para Processos

Utilizando multiprocessing.Lock:

import os
import time
from multiprocessing import Process, Lock

def executar_tarefa(controle_acesso):
    # Adquire o fecho para o processo
    controle_acesso.acquire()
    pid_atual = os.getpid()
    print(f"Processo {pid_atual} está executando a tarefa.")
    time.sleep(0.1) # Simula trabalho
    print(f"Processo {pid_atual} concluiu a tarefa.")
    # Libera o fecho
    controle_acesso.release()

if __name__ == '__main__':
    # Declara um objeto de fecho
    bloqueio_processo = Lock()
    # Cria uma lista de processos
    lista_processos = [Process(target=executar_tarefa, args=(bloqueio_processo,)) for _ in range(3)]
    
    # Inicia todos os processos
    for proc in lista_processos:
        proc.start()
    
    # Aguarda a conclusão de todos os processos
    for proc in lista_processos:
        proc.join()
        
    print("Processo principal finalizado!")

A saída mostrará que os processos executam sequencialmente, garantindo que apenas um processo por vez acesse a seção crítica.

Fechos para Threads

Similarmente, com threading.Lock:

import os
import time
from threading import Thread, Lock

def executar_operacao(mutex_thread):
    # Adquire o fecho para o thread
    mutex_thread.acquire()
    id_processo = os.getpid() # Threads compartilham o mesmo PID
    nome_thread = threading.current_thread().name # Melhor para identificar threads
    print(f"Thread {nome_thread} (PID: {id_processo}) está executando.")
    time.sleep(0.1) # Simula trabalho
    print(f"Thread {nome_thread} (PID: {id_processo}) terminou a execução.")
    # Libera o fecho
    mutex_thread.release()

if __name__ == '__main__':
    # Declara um objeto de fecho
    bloqueio_threads = Lock()
    # Cria uma lista de threads
    lista_threads = [Thread(target=executar_operacao, args=(bloqueio_threads,)) for _ in range(3)]
    
    # Inicia todas as threads
    for t in lista_threads:
        t.start()
    
    # Aguarda a conclusão de todas as threads
    for t in lista_threads:
        t.join()
        
    print("Programa principal finalizado!")

Resolvendo Condições de Corrida em Acesso a Arquivos com Mutexes

Quando múltiplos processos tentam modificar o mesmo arquivo simultaneamente, podem ocorrer condições de corrida, levando a dados corrompidos ou inconsistentes. Considere um sistema de compra de ingressos:

Cenário Sem Fecho (Problema)

import json
import os
import time
from multiprocessing import Process

ARQUIVO_INGRESSOS = os.path.join(os.path.dirname(__file__), 'ingressos.json')

def ler_dados(caminho_arquivo):
    with open(caminho_arquivo, mode='r', encoding='utf8') as f_obj:
        dados = json.load(f_obj)
    return dados

def salvar_dados(caminho_arquivo, dados_atuais):
    with open(caminho_arquivo, mode='w', encoding='utf8') as f_obj:
        json.dump(dados_atuais, f_obj, ensure_ascii=False, indent=4)

def comprar_ingresso_sem_bloqueio():
    print("Verificando disponibilidade de ingressos...")
    time.sleep(0.05) # Simula delay
    dados_atuais = ler_dados(ARQUIVO_INGRESSOS)
    quantidade = dados_atuais.get("total_ingressos")
    print(f"[{os.getpid()}] Ingressos disponíveis: {quantidade}")

    if quantidade <= 0:
        print(f"[{os.getpid()}] Ingressos esgotados!")
    else:
        print(f"[{os.getpid()}] Compra bem-sucedida!")
        dados_atuais["total_ingressos"] -= 1
    salvar_dados(ARQUIVO_INGRESSOS, dados_atuais)

if __name__ == '__main__':
    # Inicializa o arquivo de ingressos com 2 disponíveis
    estado_inicial = {"total_ingressos": 2}
    salvar_dados(ARQUIVO_INGRESSOS, estado_inicial)

    print("Iniciando simulação de compra sem fecho...")
    clientes = [Process(target=comprar_ingresso_sem_bloqueio) for _ in range(5)]
    for cliente in clientes:
        cliente.start()
    for cliente in clientes:
        cliente.join()
    print("Simulação sem fecho concluída. Verifique 'ingressos.json'.")

Neste cenário, múltiplos processos podem ler o mesmo valor de ingressos simultaneamente antes que qualquer um deles tenha a chance de decrementar e salvar. Isso leva a um problema de "supervenda" onde mais ingressos do que o disponível são vendidos.

Cenário Com Fecho (Solução)

import json
import os
import time
from multiprocessing import Process, Lock

ARQUIVO_INGRESSOS_BLOQUEADO = os.path.join(os.path.dirname(__file__), 'ingressos_bloqueados.json')

def ler_dados_bloqueados(caminho_arquivo):
    with open(caminho_arquivo, mode='r', encoding='utf8') as f_obj:
        dados = json.load(f_obj)
    return dados

def salvar_dados_bloqueados(caminho_arquivo, dados_atuais):
    with open(caminho_arquivo, mode='w', encoding='utf8') as f_obj:
        json.dump(dados_atuais, f_obj, ensure_ascii=False, indent=4)

def comprar_ingresso_com_bloqueio(mutex_compra):
    mutex_compra.acquire() # Adquire o fecho
    try:
        print(f"[{os.getpid()}] Verificando disponibilidade de ingressos...")
        time.sleep(0.05) # Simula delay
        dados_atuais = ler_dados_bloqueados(ARQUIVO_INGRESSOS_BLOQUEADO)
        quantidade = dados_atuais.get("total_ingressos")
        print(f"[{os.getpid()}] Ingressos disponíveis: {quantidade}")

        if quantidade <= 0:
            print(f"[{os.getpid()}] Ingressos esgotados!")
        else:
            print(f"[{os.getpid()}] Compra bem-sucedida!")
            dados_atuais["total_ingressos"] -= 1
        salvar_dados_bloqueados(ARQUIVO_INGRESSOS_BLOQUEADO, dados_atuais)
    finally:
        mutex_compra.release() # Libera o fecho, mesmo se ocorrer um erro

if __name__ == '__main__':
    # Inicializa o arquivo de ingressos com 2 disponíveis
    estado_inicial = {"total_ingressos": 2}
    salvar_dados_bloqueados(ARQUIVO_INGRESSOS_BLOQUEADO, estado_inicial)

    # Declara um objeto de fecho para proteger o arquivo
    bloqueio_compra = Lock()

    print("\nIniciando simulação de compra COM fecho...")
    clientes_protegidos = [Process(target=comprar_ingresso_com_bloqueio, args=(bloqueio_compra,)) for _ in range(5)]
    for cliente in clientes_protegidos:
        cliente.start()
    for cliente in clientes_protegidos:
        cliente.join()
    print("Simulação COM fecho concluída. Verifique 'ingressos_bloqueados.json'.")

Com o fecho, cada processo adquire o bloqueio antes de ler, decrementar e salvar, garantindo exclusão mútua e prevenindo a supervenda.

Impasse (Deadlock)

from threading import Thread, Lock
import time

lock_recurso_a = Lock()
lock_recurso_b = Lock()

class OperacaoConcorrente(Thread):
    def run(self):
        self.executar_parte1()
        self.executar_parte2()

    def executar_parte1(self):
        lock_recurso_a.acquire()
        print(f'{self.name} adquiriu o Recurso A')
        time.sleep(0.1) # Pequeno atraso para expor o impasse
        lock_recurso_b.acquire()
        print(f'{self.name} adquiriu o Recurso B')
        lock_recurso_b.release()
        lock_recurso_a.release()

    def executar_parte2(self):
        lock_recurso_b.acquire()
        print(f'{self.name} adquiriu o Recurso B (na parte 2)')
        time.sleep(0.1) # Pequeno atraso para expor o impasse
        lock_recurso_a.acquire()
        print(f'{self.name} adquiriu o Recurso A (na parte 2)')
        lock_recurso_a.release()
        lock_recurso_b.release()

def iniciar_impasses():
    for i in range(2): # Apenas 2 threads para demonstrar o impasse claramente
        t = OperacaoConcorrente()
        t.start()

if __name__ == '__main__':
    iniciar_impasses()
    # A saída mostrará que as threads podem travar, por exemplo:
    # Thread-1 adquiriu o Recurso A
    # Thread-2 adquiriu o Recurso B (na parte 2)
    # Neste ponto, Thread-1 precisa de B, e Thread-2 precisa de A, resultando em impasse.

Um impasse (deadlock) é uma situação de bloqueio mútuo entre dois ou mais threads/processos, onde cada um espera por um recurso que está sendo mantido por outro, criando um ciclo de espera infinita. Isso paralisa o programa.

Condições Necessárias para Impasses:

  1. Exclusão Mútua: Um recurso só pode ser usado por um thread/processo por vez.
  2. Retenção e Espera: Um thread/processo detém um recurso enquanto espera por outro.
  3. Não Preempção: Recursos não podem ser tomados à força de um thread/processo; só podem ser liberados voluntariamente.
  4. Espera Circular: Existe um ciclo onde cada thread/processo no ciclo está esperando por um recurso detido pelo próximo thread/processo no ciclo.

Estratégias para Evitar Impasses:

  1. Ordem Fixa de Aquisição de Fechos: Garanta que todos os threads adquiram os fechos na mesma ordem predefinida.
  2. Mecanismos de Timeout: Ao tentar adquirir um fecho, defina um tempo limite. Se o fecho não for obtido dentro desse período, o thread pode abortar ou tentar novamente.
  3. Evitar Fechos Aninhados: Minimize a prática de adquirir um fecho enquanto já se detém outro, reduzindo a chance de "retenção e espera".
  4. Detecção e Recuperação de Impasses: Implementar algoritmos que detectem impasses e tomem ações para quebrá-los (como preempção ou reinício de processos).

Fechos Recursivos (RLock)

Um fecho recursivo, ou reentrante, permite que o mesmo thread o adquira múltiplas vezes sem bloquear a si mesmo. Ele mantém uma contagem interna; cada chamada a acquire() incrementa a contagem e cada release() a decrementa. O fecho só é liberado para outros threads quando a contagem chega a zero.

RLocks são ideais para situações onde uma função que já detém um fecho precisa chamar outra função que também tenta adquirir o mesmo fecho (código recursivo ou aninhado).

Em Python, threading.RLock implementa essa funcionalidade.

from threading import Thread, RLock
import time

# Ambos os nomes de fecho apontam para o mesmo objeto RLock
fecho_recurso_X = RLock()
fecho_recurso_Y = fecho_recurso_X # fecho_recurso_Y é uma referência ao mesmo RLock

class ThreadComRLock(Thread):
    def run(self):
        self.metodo_a()
        self.metodo_b()

    def metodo_a(self):
        fecho_recurso_X.acquire()
        print(f'{self.name} adquiriu RLock X.')
        fecho_recurso_Y.acquire() # O mesmo thread pode adquirir novamente
        print(f'{self.name} adquiriu RLock Y (recursivamente).')
        fecho_recurso_Y.release()
        fecho_recurso_X.release()

    def metodo_b(self):
        fecho_recurso_Y.acquire()
        print(f'{self.name} adquiriu RLock Y (no método b).')
        time.sleep(0.05) # Pequeno atraso
        fecho_recurso_X.acquire() # O mesmo thread pode adquirir novamente
        print(f'{self.name} adquiriu RLock X (no método b recursivamente).')
        fecho_recurso_X.release()
        fecho_recurso_Y.release()

def executar_com_rlocks():
    for i in range(3):
        t = ThreadComRLock()
        t.start()

if __name__ == '__main__':
    executar_com_rlocks()

Variáveis de Condição

Uma variável de condição (threading.Condition em Python) combina um fecho com a capacidade de threads esperarem por uma condição específica e serem notificados quando essa condição for atendida. É ideal para coordenação complexa de threads, como em problemas de produtor-consumidor.

Analisando a implementação, é possível especificar o tipo de fecho subjacente (Lock ou RLock). Se nenhum for fornecido, um RLock é criado por padrão.

Métodos Chave:

  1. acquire(self, \*args): Adquire o fecho subjacente.
  2. release(self): Libera o fecho subjacente.
  3. wait(self, timeout=None): Libera o fecho subjacente e bloqueia o thread até ser notificado ou até o timeout expirar. Ao ser notificado, readquire o fecho antes de retornar.
  4. notify(self, n=1): Acorda até n threads que estão esperando na variável de condição.
  5. notify\_all(self): Acorda todos os threads que estão esperando na variável de condição.

Exemplo Prático (Produtor-Consumidor):

import threading
import time
import random

# Recurso compartilhado: uma fila simples
fila_itens = []
CAPACIDADE_FILA = 5

# Cria uma variável de condição
condicao_fila = threading.Condition()

def funcao_produtor():
    global fila_itens
    for i in range(1, 11): # Produz 10 itens
        with condicao_fila:
            # Espera se a fila estiver cheia
            while len(fila_itens) == CAPACIDADE_FILA:
                print(f"Produtor: Fila cheia. Aguardando para produzir...")
                condicao_fila.wait() # Libera o fecho e espera

            item_novo = f"Item-{i}"
            fila_itens.append(item_novo)
            print(f"Produtor: Produziu {item_novo}. Fila: {fila_itens}")
            condicao_fila.notify_all() # Notifica consumidores
        time.sleep(random.uniform(0.5, 1.5))

def funcao_consumidor():
    global fila_itens
    for _ in range(10): # Consome 10 itens
        with condicao_fila:
            # Espera se a fila estiver vazia
            while not fila_itens:
                print(f"Consumidor: Fila vazia. Aguardando por itens...")
                condicao_fila.wait() # Libera o fecho e espera

            item_consumido = fila_itens.pop(0)
            print(f"Consumidor: Consumiu {item_consumido}. Fila: {fila_itens}")
            condicao_fila.notify_all() # Notifica produtores
        time.sleep(random.uniform(0.5, 1.5))

if __name__ == "__main__":
    produtor_thread = threading.Thread(target=funcao_produtor, name="ProdutorThread")
    consumidor_thread = threading.Thread(target=funcao_consumidor, name="ConsumidorThread")

    produtor_thread.start()
    consumidor_thread.start()

    produtor_thread.join()
    consumidor_thread.join()
    print("Simulação de Produtor-Consumidor finalizada.")

Semáforos

Semáforos são mecanismos de sincronização que controlam o acesso a um conjunto limitado de recursos. Eles são compostos por um contador e operações atômicas que manipulam esse contador. O contador de um semáforo representa o número de acessos disponíveis ao recurso.

Operações Básicas:

  1. P (ou acquire()): Decrementa o contador. Se o contador se tornar negativo, o thread é bloqueado.
  2. V (ou release()): Incrementa o contador. Se houver threads bloqueados, um deles é acordado.

Em Python, threading.Semaphore permite criar semáforos. O valor inicial do semáforo (value) define a capacidade máxima de acessos simultâneos. Se não especificado, o valor padrão é 1, tornando-o funcionalmente similar a um mutex.

Métodos Chave:

  1. threading.Semaphore(value=1): Inicializa o semáforo com um contador.
  2. acquire(blocking=True, timeout=None): Tenta decrementar o contador. Se o contador for > 0, ele é decrementado e a função retorna. Caso contrário, o thread bloqueia até que o contador seja > 0 ou até o timeout.
  3. release(): Incrementa o contador do semáforo. Acorda um thread se houver algum esperando.

Exemplo de Semáforo:

import threading
import time

# Cria um semáforo com capacidade para 2 acessos simultâneos
sem = threading.Semaphore(2)

def processo_trabalhador():
    sem.acquire() # Tenta adquirir uma "permissão"
    nome_thread = threading.current_thread().name
    print(f"{nome_thread} iniciou a tarefa (slot adquirido).")
    time.sleep(random.uniform(0.5, 1.5)) # Simula trabalho
    print(f"{nome_thread} concluiu a tarefa (slot liberado).")
    sem.release() # Libera a "permissão"

# Cria 5 threads que tentarão acessar um recurso limitado a 2 por vez
threads_trabalhadoras = [threading.Thread(target=processo_trabalhador, name=f"Trab-{i}") for i in range(5)]

for t in threads_trabalhadoras:
    t.start()

for t in threads_trabalhadoras:
    t.join()

print("Todos os trabalhadores finalizaram.")

É também possível usar semáforos com a sintaxe with:

import threading
import time
import random

# Cria um semáforo com capacidade para 2 acessos simultâneos
sem_contexto = threading.Semaphore(2)

def processo_com_contexto():
    with sem_contexto: # Adquire e libera automaticamente
        nome_thread = threading.current_thread().name
        print(f"{nome_thread} iniciou a tarefa (via 'with').")
        time.sleep(random.uniform(0.5, 1.5))
        print(f"{nome_thread} concluiu a tarefa (via 'with').")

threads_contexto = [threading.Thread(target=processo_com_contexto, name=f"Contexto-{i}") for i in range(5)]

for t in threads_contexto:
    t.start()

for t in threads_contexto:
    t.join()

print("Todos os trabalhadores com contexto finalizaram.")

Semáforos com ThreadPoolExecutor

Combinar semáforos com um pool de threads é útil para limitar o acesso a um recurso específico, mesmo que o pool tenha mais threads do que a capacidade do recurso.

import threading
import time
from concurrent.futures import ThreadPoolExecutor
import random

def tarefa_com_recurso_limitado(sem_recurso, id_recurso):
    with sem_recurso: # Controla o acesso ao recurso
        nome_thread = threading.current_thread().name
        print(f"[{nome_thread}] Adquiriu slot para recurso {id_recurso}.")
        time.sleep(random.uniform(0.5, 1.0)) # Simula uso do recurso
        print(f"[{nome_thread}] Usou e liberou recurso {id_recurso}.")
    return f"Recurso {id_recurso} processado."

if __name__ == "__main__":
    # Cria um semáforo que permite 2 acessos simultâneos ao "recurso"
    semaforo_recursos = threading.Semaphore(value=2)

    # Cria um pool de threads com 3 trabalhadores
    with ThreadPoolExecutor(max_workers=3) as executor:
        # Submete 5 tarefas. O pool pode rodar 3 threads, mas o semáforo limita o recurso a 2.
        submissoes = [executor.submit(tarefa_com_recurso_limitado, semaforo_recursos, f"DADO-{i}") for i in range(5)]

        for futuro in submissoes:
            print(futuro.result())
    print("Todas as tarefas com pool e semáforo concluídas.")

Fechos de Evento (Event)

Um evento (threading.Event) é uma ferramenta de comunicação entre threads. Ele permite que um thread sinalize a ocorrência de um evento, e outros threads esperem por esse sinal. Um evento tem um flag interno que pode estar "set" (verdadeiro) ou "clear" (falso).

Operações:

  1. set(): Define o flag interno como verdadeiro, acordando todos os threads que estão esperando.
  2. clear(): Define o flag interno como falso, fazendo com que futuras chamadas a wait() bloqueiem.
  3. wait(timeout=None): Bloqueia até que o flag interno seja verdadeiro. Se o flag já for verdadeiro, retorna imediatamente.

Eventos são geralmente "uma vez", no sentido de que um set() acordará todos os wait() atuais e futuros até que clear() seja chamado. Para comunicação contínua baseada em condições, Condition pode ser mais apropriado.

Exemplo Básico de Evento:

import threading
import time
import random

# Cria um objeto Event
meu_evento = threading.Event()

def trabalhador_evento(nome_trab):
    print(f"[{nome_trab}] Aguardando o sinal...")
    meu_evento.wait() # Bloqueia até que meu_evento.set() seja chamado
    print(f"[{nome_trab}] Sinal recebido! Prosseguindo...")
    # Faz algum trabalho após o sinal
    time.sleep(random.uniform(0.1, 0.5))
    print(f"[{nome_trab}] Concluiu sua parte.")

# Cria vários threads trabalhadores
threads_evento = [threading.Thread(target=trabalhador_evento, args=(f"Trab-{i}",)) for i in range(3)]

for t in threads_evento:
    t.start()

# O thread principal espera um pouco e então sinaliza o evento
time.sleep(2)
print("\n=== Thread Principal: Enviando o sinal! ===\n")
meu_evento.set() # Sinaliza o evento, acordando os trabalhadores

for t in threads_evento:
    t.join()

print("\nTodos os threads finalizaram após o evento.")

Jogo da Esconde-Esconde com Eventos:

import random
import threading
import time

sinal_jogo = threading.Event()

def jogador_esconderijo():
    nome_jogador = threading.current_thread().name
    print(f"[{nome_jogador}]: Estou me escondendo!")
    time.sleep(random.uniform(0.5, 1.0))
    print(f"[{nome_jogador}]: Escondido! Sinalizando para começar a procurar.")
    sinal_jogo.set() # Sinaliza que está pronto
    
    sinal_jogo.wait() # Espera o procurador sinalizar o fim
    print(f"[{nome_jogador}]: Fim de jogo para o esconderijo!")

def jogador_procurador():
    nome_jogador = threading.current_thread().name
    print(f"[{nome_jogador}]: Início do jogo! Vou esperar você se esconder.")
    sinal_jogo.wait() # Espera o esconderijo sinalizar
    
    print(f"[{nome_jogador}]: Você se escondeu? Ok, estou procurando!")
    time.sleep(random.uniform(1.0, 2.0))
    
    achou = random.choice([True, False])
    if achou:
        print(f"[{nome_jogador}]: Te encontrei!")
    else:
        print(f"[{nome_jogador}]: Não te encontrei desta vez.")
    sinal_jogo.set() # Sinaliza o fim da procura

if __name__ == '__main__':
    esconderijo_t = threading.Thread(target=jogador_esconderijo, name="Esconderijo")
    procurador_t = threading.Thread(target=jogador_procurador, name="Procurador")
    
    procurador_t.start()
    time.sleep(0.1) # Garante que o procurador comece a esperar primeiro
    esconderijo_t.start()
    
    esconderijo_t.join()
    procurador_t.join()
    print("\nJogo da Esconde-Esconde Encerrado.")

Global Interpreter Lock (GIL)

O GIL (Global Interpreter Lock) é um mecanismo presente na implementação CPython do interpretador Python que garante que apenas um thread por vez possa executar bytecode Python. Ele foi introduzido para simplificar a implementação do CPython e proteger suas estruturas de dados internas de acessos concorrentes por múltiplos threads.

Pontos Chave sobre o GIL:

  1. Natureza Global: O GIL é um fecho global que bloqueia todo o interpretador, impedindo que múltiplos threads de Python executem código Python simultaneamente.
  2. Específico do CPython: O GIL é uma característica da implementação CPython. Outros interpretadores Python (como Jython ou IronPython) não possuem GIL, e algumas implementações alternativas de Python estão explorando formas de remover ou desativar o GIL.
  3. Impacto no Desempenho Multi-threading: Para tarefas intensivas em CPU, o GIL limita severamente o ganho de desempenho em aplicações multi-threaded em sistemas com múltiplos núcleos, pois apenas um núcleo pode estar ativamente executando código Python por vez.
  4. Menor Impacto em Tarefas I/O-Intensivas: O GIL é liberado automaticamente durante operações de I/O de longa duração (como leitura/escrita de arquivos, requisições de rede, time.sleep()). Isso permite que outros threads executem código Python enquanto o primeiro thread aguarda a conclusão da operação de I/O. Por essa razão, multi-threading em Python ainda é eficaz para aplicações I/O-bound.
  5. Alternativa Multi-processamento: Para tarefas CPU-intensivas que precisam aproveitar múltiplos núcleos, a solução comum em Python é usar multi-processamento (módulos como multiprocessing). Cada processo tem seu próprio interpretador Python e, portanto, seu próprio GIL, permitindo execução paralela real.
  6. Não é um Substituidor de Fechos Explícitos: Embora o GIL evite algumas condições de corrida de baixo nível (protegendo as estruturas internas do interpretador), ele NÃO substitui a necessidade de fechos explícitos para proteger a lógica da sua aplicação e estruturas de dados personalizadas contra inconsistências lógicas em ambientes multi-threaded.

Historicamente, o GIL facilitou o desenvolvimento do CPython e de muitas extensões C. Para a maioria das aplicações, o gargalo não é o GIL. No entanto, para cargas de trabalho de computação intensiva, é crucial considerar multi-processamento ou mover partes do código para extensões C otimizadas que podem liberar o GIL.

Demonstração do GIL (Observação de Comportamento):

import os
from threading import Thread
import time

contador_global = 50

def diminuir_contador():
    global contador_global
    # O GIL garante que a operação de decremento 'num -= 1' será atômica
    # em CPython, mas isto não é um substituto para fechos lógicos complexos.
    temp_val = contador_global # Leitura
    time.sleep(0.01) # Pequena pausa para permitir troca de threads (o GIL é liberado e readquirido)
    contador_global = temp_val - 1 # Escrita
    print(f"[{threading.current_thread().name} | PID: {os.getpid()}] Valor: {contador_global}")

if __name__ == '__main__':
    threads_gil = [Thread(target=diminuir_contador, name=f"Thread-{i}") for i in range(5)]
    for t in threads_gil:
        t.start()
    for t in threads_gil:
        t.join()
    print("=== Simulação GIL concluída ===")
    print(f"Valor final do contador global: {contador_global}")

Neste exemplo simples, devido à natureza do GIL em CPython, mesmo sem um fecho explícito, a operação contador\_global -= 1 pode parecer "segura" para múltiplos threads no sentido de que o valor final pode ser o esperado (50 - 5 = 45). Isso ocorre porque o GIL serilaiza a execução do bytecode Python. No entanto, se a operação fosse mais complexa ou envolvesse múltiplos passos que não fossem atômicos no nível do bytecode, um fecho explícito ainda seria necessário para garantir a correção lógica.

Tags: Python Concurrency Multithreading Multiprocessing Locks

Publicado em 7-20 11:42