Paralelismo executa múltiplas tarefas ao mesmo tempo fisicamente. Concorrência lida com várias tarefas avançando em sobreposição. Primeiramente, o paralelismo exige múltiplos núcleos de CPU. Por exemplo, processar 10 imagens simultaneamente em 10 núcleos. Já a concorrência pode rodar em um único núcleo. Ela alterna entre tarefas para dar a sensação de simultaneidade. A voz passiva é usada aqui: “as tarefas são intercaladas pelo escalonador”. Quando utilizar cada uma? Paralelismo para tarefas com CPU intensiva. Concorrência para operações de I/O (rede, disco, banco de dados). Python oferece threading, asyncio e multiprocessing para isso. Vamos explorar cada abordagem com exemplos práticos. Três subtítulos guiarão você pelas principais técnicas. Ao final, você saberá qual ferramenta escolher.
Threading: concorrência para i/o-bound
Threading permite múltiplas threads dentro de um único processo. No Python, o GIL (Global Interpreter Lock) limita a execução paralela. Portanto, threads não aceleram código com CPU intensivo. Por outro lado, threads são excelentes para operações de I/O. Por exemplo, baixar vários arquivos da internet simultaneamente. Quando usar threading? Em aplicações com muitas esperas externas. A voz passiva é aplicada: “os bloqueios de I/O são liberados durante a espera”. Exemplo de threading para downloads simultâneos:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
import threading import time import requests # Lista de URLs para baixar urls = [ 'https://httpbin.org/delay/1', 'https://httpbin.org/delay/2', 'https://httpbin.org/delay/1.5', 'https://httpbin.org/delay/0.5', ] def baixar_url(url, nome_thread): """Função que simula download de uma URL.""" print(f"[{nome_thread}] Iniciando download de {url}") inicio = time.time() resposta = requests.get(url) fim = time.time() print(f"[{nome_thread}] Concluído em {fim - inicio:.2f}s - Status: {resposta.status_code}") # Versão sequencial (sem threading) print("=== Execução sequencial ===") inicio_seq = time.time() for i, url in enumerate(urls): baixar_url(url, f"Seq-{i}") print(f"Tempo total sequencial: {time.time() - inicio_seq:.2f}s\n") # Versão com threading print("=== Execução com threading ===") threads = [] inicio_thread = time.time() for i, url in enumerate(urls): t = threading.Thread(target=baixar_url, args=(url, f"Thread-{i}")) threads.append(t) t.start() # Aguardar todas as threads terminarem for t in threads: t.join() print(f"Tempo total com threading: {time.time() - inicio_thread:.2f}s") |
O threading reduz drasticamente o tempo total para I/O. Neste exemplo, a versão sequencial leva cerca de 5 segundos. A versão com threads leva apenas o tempo da operação mais longa (~2s). Isso é o poder da concorrência para operações de espera.
Multiprocessing: paralelismo para cpu-bound
Multiprocessing cria processos separados, cada um com seu próprio GIL. Isso permite usar múltiplos núcleos da CPU simultaneamente. Quando usar multiprocessing? Em tarefas que consomem muito processamento. Por exemplo, cálculos matemáticos, processamento de imagens ou criptografia. A sobrecarga de criar processos é maior que a de threads. Portanto, use apenas para tarefas realmente pesadas. Exemplo de multiprocessing para calcular números primos:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
import multiprocessing import time import math def eh_primo(n): """Verifica se um número é primo (operação CPU-bound).""" if n < 2: return False for i in range(2, int(math.sqrt(n)) + 1): if n % i == 0: return False return True def contar_primos(inicio, fim): """Conta quantos primos existem no intervalo [inicio, fim).""" count = 0 for num in range(inicio, fim): if eh_primo(num): count += 1 return count if __name__ == '__main__': intervalo = (0, 100000) # 0 a 99.999 num_processos = 4 # Versão sequencial inicio_seq = time.time() total_seq = contar_primos(*intervalo) tempo_seq = time.time() - inicio_seq print(f"Sequencial: {total_seq} primos em {tempo_seq:.2f}s") # Dividir o trabalho entre processos tamanho = (intervalo[1] - intervalo[0]) // num_processos partes = [] for i in range(num_processos): ini = intervalo[0] + i * tamanho fim = intervalo[0] + (i + 1) * tamanho if i < num_processos - 1 else intervalo[1] partes.append((ini, fim)) # Versão com multiprocessing inicio_par = time.time() with multiprocessing.Pool(processes=num_processos) as pool: resultados = pool.starmap(contar_primos, partes) total_par = sum(resultados) tempo_par = time.time() - inicio_par print(f"Paralelo ({num_processos} processos): {total_par} primos em {tempo_par:.2f}s") print(f"Aceleração: {tempo_seq / tempo_par:.2f}x") |
O ganho com multiprocessing é próximo ao número de núcleos. Em uma máquina com 4 núcleos, a aceleração pode chegar a 3.5x. Isso é paralelismo real, não apenas concorrência.
Asyncio: concorrência assíncrona eficiente
Asyncio usa um event loop para gerenciar tarefas concorrentes. Ele não usa threads nem processos, apenas uma única thread. Quando usar asyncio? Em aplicações com muitas conexões de rede. Por exemplo, servidores web, chats ou proxies. A vantagem é o baixo consumo de memória e overhead reduzido. A desvantagem é que todo o código precisa ser assíncrono. Exemplo de asyncio para múltiplas requisições HTTP:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
import asyncio import aiohttp import time async def buscar_url(sessao, url, nome): """Busca uma URL de forma assíncrona.""" print(f"[{nome}] Iniciando {url}") inicio = time.time() async with sessao.get(url) as resposta: texto = await resposta.text() fim = time.time() print(f"[{nome}] Concluído em {fim - inicio:.2f}s") return texto[:50] # Retorna apenas os primeiros 50 caracteres async def main(): urls = [ ('https://httpbin.org/delay/1', 'Site A'), ('https://httpbin.org/delay/2', 'Site B'), ('https://httpbin.org/delay/1.5', 'Site C'), ('https://httpbin.org/delay/0.5', 'Site D'), ] inicio = time.time() async with aiohttp.ClientSession() as sessao: # Criar tarefas para todas as URLs tarefas = [buscar_url(sessao, url, nome) for url, nome in urls] resultados = await asyncio.gather(*tarefas) print(f"\nTempo total assíncrono: {time.time() - inicio:.2f}s") for i, resultado in enumerate(resultados): print(f"Resultado {i}: {resultado}...") if __name__ == '__main__': asyncio.run(main()) |
Asyncio é ideal para milhares de conexões simultâneas. Ele escala melhor que threads para I/O intensivo. A fórmula de eficiência é: \(E = \frac{R_{\text{asyncio}}}{R_{\text{threading}}} \approx 1.5\) Para muitos casos, asyncio usa menos memória e CPU. A escolha final depende do seu problema específico. Threading é mais simples para iniciantes em concorrência. Multiprocessing é obrigatório para CPU-bound. Asyncio é o futuro para servidores de alta performance. Domine as três técnicas e resolva qualquer desafio.