Uma pequena introdução a programação paralela e multiprocessamento com python
Tags: python
Published: 2020-04-17 23:24:48

Hoje acho que é hora de começar a tratar de um tema que é muito discutido mas eu acho que os iniciantes tem dificuldade pra visualizar na cabeça. Concorrência e Paralelismo.

Hoje em dia todo mundo tem um computador potente. É comum encontrar processadores com 4, 6 ou até 8 núcleos. Mesmo o mais fraco Intel i3 é dual core com 3 GHz ou até mesmo seu celular Android, com o chipset Snapdragon 632 Qualcomm SDM632, é nada menos que Octa Core com 1.8 GHz cada. Então, do seu celular, a desktop, servidores, estamos claramente num mundo massivamente paralelo.

No entanto, o intérprete Python padrão foi projetado e possui um mecanismo seguro para threads, o chamado “GIL” (Global Interpreter Lock). Para evitar conflitos entre as threads, ele executa apenas uma instrução de cada vez (o chamado processamento serial ou single-threading).
Nesta introdução irei falar de forma breve alguns conceitos importantes para melhor entendimento o que são **Concorrência** e **Paralelismo**, **Contexto, Threads** e **Processos** e começar a falar sobre o modulo python multiprocessing e ao contrário do que possa parecer, na realidade o básico não é tão complicado assim.

# Concorrência e Paralelismo

É comum achar que concorrência e paralelismo são a mesma coisa, mas não. Rob Pike, um dos criadores da linguagem Go, explicou em uma apresentação:

> Concorrência é sobre **lidar** com várias coisas ao mesmo tempo e paralelismo é sobre **fazer** várias coisas ao mesmo tempo.

Mas vamos a uma definição mais formal:

> **Concorrência**: "É a capacidade de se executar duas ou mais tarefas em um mesmo período de tempo"
**Paralelismo**: "Paralelismo é sobre a execução paralela de tarefas, ou seja, mais de uma por vez de forma simultânea"

# Contexto

Contexto é usado quando uma tarefa poder ser interrompida e em algum momento voltar a ser executada exatamente onde parou.. Porém, existe um custo alto então quanto menor o contexto menor é a latência.

# Threads

Em sistemas operacionais tradicionais, cada processo tem um espaço de endereçamento e um único thread (fluxo) de controle. As threads são utilizadas para que múltiplas execuções ocorram no mesmo ambiente do processo com um grande grau de independência uma da outra.
Como cada thread pode ter acesso a qualquer endereço de memória dentro do espaço de endereçamento do processo, uma thread pode ler, escrever ou até mesmo apagar completamente a pilha de outra thread.

# Módulo de multiprocessamento do Python

Aqui, veremos o módulo [multiprocessing](https://docs.python.org/dev/library/multiprocessing.html) do Python e como podemos usá-lo para enviar vários processos que podem ser executados independentemente um do outro, a fim de fazer o melhor uso dos nossos núcleos de CPU.

![alt text][logo]

[logo]: https://miro.medium.com/max/696/1*ybgI4bs3B2BfVlBMDQ_yBA.png "Compare Processo Paralelo e Serial"

O módulo de multiprocessamento na Biblioteca Padrão do Python possui muitos recursos poderosos. Se você quiser ler sobre todos os detalhes e ir mais a fundo recomendo o uso da documentação oficial como ponto de entrada.

Nas seção a seguir, desejo fornecer uma breve visão geral para mostrar como o módulo de multiprocessing poderá ser usado para programação paralela.

Multiprocessing oferece vários recursos hoje eu irei focar na Classe Pool que é mais conveniente para tarefas simples de processamento paralelo.

Existem quatro métodos que são particularmente interessantes:

- Pool.apply
- Pool.map
- Pool.apply_async
- Pool.map_async

Os métodos **Pool.apply** e **Pool.map** são basicamente equivalentes às funções apply e map nativas do Python. Antes de chegarmos às variantes async dos métodos Pool, vamos dar uma olhada em um exemplo simples usando Pool.apply e Pool.map. Aqui definiremos o número de processos como 4, o que significa que a classe Pool permitirá apenas 4 processos em execução ao mesmo tempo.

```python
from random import randint
from time import sleep, time
from multiprocessing.pool import ThreadPool

def print_names(name):
    sleep(randint(1, 3))
    print('Meu nome é: {}'.format(name))

runtime = []
threads = []
names = ['Adson', 'Gabriel', 'Siqueira', 'Ronaldo', 'Gleilson',
         'Emerson', 'Joselito', 'Piloto', 'Kleber', 'Mauricio']

pool = ThreadPool(processes=4)
start = time()

for name in names:
    async_result = pool.apply(print_names, (name,))
    threads.append(async_result)

end = time()
print('tempo de execução da tradução: {}'.format(end - start))
```

**tempo de execução da tradução**: 21.03 segundos

```python
from random import randint
from time import sleep, time
from multiprocessing.pool import ThreadPool


def print_names(name):
    sleep(randint(1, 3))
    print('Meu nome é: {}'.format(name))


runtime = []
threads = []
names = ['Adson', 'Gabriel', 'Siqueira', 'Ronaldo', 'Gleilson',
         'Emerson', 'Joselito', 'Piloto', 'Kleber', 'Mauricio']

pool = ThreadPool(processes=4)
start = time()

for name in names:
    async_result = pool.map(print_names, (name,))
    threads.append(async_result)

end = time()
print('tempo de execução da tradução: {}'.format(end - start))
```

**tempo de execução da tradução**: 24.03 segundos

O Pool.map e Pool.apply bloqueará o programa principal até que todos os processos sejam concluídos, o que é bastante útil se queremos obter resultados ordenados.

Por outro lado, as variantes async enviarão todos os processos de uma só vez e recuperarão os resultados assim que terminarem. Outra diferença que é bastante importante é que precisamos usar o método get() após a chamada de apply_async() para obter os valores dos processos concluídos.

```python
from random import randint
from time import sleep, time
from multiprocessing.pool import ThreadPool


def print_names(name):
    sleep(randint(1, 3))
    print('Meu nome é: {}'.format(name))


runtime = []
threads = []
names = ['Adson', 'Gabriel', 'Siqueira', 'Ronaldo', 'Gleilson',
         'Emerson', 'Joselito', 'Piloto', 'Kleber', 'Mauricio']

pool = ThreadPool(processes=4)
start = time()

for name in names:
    async_result = pool.map_async(print_names, (name,))
    threads.append(async_result)

letters_list = [result.get() for result in threads]

end = time()
print('tempo de execução da tradução: {}'.format(end - start))
```

**tempo de execução da tradução**: 6.00 segundos

```python
from random import randint
from time import sleep, time
from multiprocessing.pool import ThreadPool


def print_names(name):
    sleep(randint(1, 3))
    print('Meu nome é: {}'.format(name))


runtime = []
threads = []
names = ['Adson', 'Gabriel', 'Siqueira', 'Ronaldo', 'Gleilson',
         'Emerson', 'Joselito', 'Piloto', 'Kleber', 'Mauricio']

pool = ThreadPool(processes=4)
start = time()

for name in names:
    async_result = pool.apply_async(print_names, (name,))
    threads.append(async_result)

letters_list = [result.get() for result in threads]

end = time()
print('tempo de execução da tradução: {}'.format(end - start))
```

**tempo de execução da tradução**: 6.01 segundos

Explicar concorrência e paralelismo só falando é muito difícil. Vocês ainda vão precisar praticar programando centenas de vezes coisas diferentes pra começar a entender de verdade como as coisas funcionam. Eu espero que a explicação que eu dei agora pelo menos ajude um pouco vocês terem uma imagem na cabeça do que esperar quando realmente colocarem as mãos na massa.

Se tiverem dúvidas não deixem de mandar nos comentários abaixo. Valeu!