A linguagem Python padronizou a interface ao multiprocessamento com o módulo multiprocessing. Esta interface permite a criação a comunicação entre processos, criados local ou remotamente.
A interface deste módulo replica várias características da interface do módulo de Threads, mas também introduz muitas novas facilidades. Vamos seguir mais ou menos o roteiro que fizemos quando apresentamos threds, depois vamos mostrar as novidades associadas ao multiprocessamento.
É importante entender que a criação de processos e o gerenciamento da comunicação entre eles são características de todos os sistemas operacionais modernos, isto é feito há várias décadas, pode ser acessado de muitas linguagens de programação. Por exemplo, as operações básicas do unix tradicional ou do linux são baseadas no modelo fork and join. Este módulo simplesmente acrescenta uma interface melhor à esta funcionalidade.
Não podemos fazer nada com multiprocessamento antes de importar o módulo de multiprocessamento, que é padrão nas versões mais novas da linguagem.
1 | |
A classe que representa um processo é chamada Process, sem muitas surpresas. Depois de criado,
a execução de um processo deve ser iniciada com .start(), e podemos esperar seu término com
.join(), exatamente com fazemos com threads.
Vamos ver alguns exemplos abaixo, mas antes vamos criar um módulo para colocarmos as funções de suporte, para diminuir o volume de código repetido em cada exemplo.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
Por enquanto só temos a função que faz o logging das mensagens, da mesma forma que para os exemplos de threading.
Vamos criar um processo, da maneira mais simples possível e praticamente identicamente ao que fizemos para a criação de threads. Tradicionalmente, chamamos o processo que cria os processos de processo pai, e os processos que são criados de processos filhos.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
Como anteriormente, o código acima é uma maluquice, pois poderíamos ter simplesmente chamado a função diretamente. As vantagens começam a aparecer quando criamos múltiplos processo, o que vamos fazer mais tarde. Antes disto, vamos ver outras características desta classe.
Assim como com theads, podemos criar uma classe que herda da classe Process e redefinir o
método .run().
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
O que é mais conveniente depende do contexto e do hábito do programador.
Uma observação interessante é que, da mesma forma que Threads, os objetos da classe Process
já tem uma membro .name, portanto este que definimos nos exemplos acima, (e todas as
vezes que fizemos isto com threads) é redundante. Este membro é só uma conveniência e não tem
nenhuma função semântica. Podemos simplificar um pouco o programa acima então,
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 | |
Perceba que o Python gera um nome default caso você não atribua um.
Um objeto da classe Process tem a mesma assinatura que um objeto da classe Thread. Em particular, podemos verificar se um processo está vivo ou não.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | |
Também podemos ver no programa acima que os processos são objetos normais que podem fazer parte de listas, tuplas, dicionários, etc.
Os processos também tem membros que não existem em Threads, como pid, que é número do
processo, exitcode, que é o código de erro de saída do processo filho.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
Existem outros membros desta classe que provavelmente não devem ser usadas por pessoas normais em condições normais. Você pode ver a documentação do módulo se estiver interessado.
Infelizmente o mecanismo para criação de processos é um pouco dependente da plataforma na qual o programa está rodando. Há três alternativas:
run(). Isto é bem caro comparado com
os outros, existe no Unix e no Windoes e é o padrão no Windows.os.fork(), que cria uma cópia na memória do interpretador Python.
O processo filhor é idêntico ao processo pai, quando começa, e todos os seus recursos são
herdados, como arquivos abertos, etc. Não é uma boa ideia usar isto se o processo pai for
multithreaded. Só existe no Unix, onde é o padrão. os.fork(). Este processo é single threaded então pode chamar fork sem problemas. Nada
não essencial é compartilhado. Disponível em apenas algumas versões do Unix.Para escolher o mecanismo de lançamento de processos usamos a função set_start_method() do
módulo multiprocessing, que só deve ser chamada uma vez por programa, na parte que é executada
uma única vez!
Este programa pode não funcionar, dependendo de onde o programa é executado. Em particular, não funciona se você rodar dentro do spyder no linux, que já seleciona o contexto automaticamente.
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 | |
Por este e outros motivos é importante que você se certifique que o código que vai ser executado como um novo processo pode ser carregado como um módulo sem que ele tente gerar outros processos. Por exemplo, o código a seguir falha.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
Perceba que a única diferença é a falta da proteção da linha
1 | |
que impede que o resto do arquivo seja executado quando é carregado como um módulo.
É interessante rodar o programa abaixo com os três métodos de lançamento e estudar os resultados no seu sistema.
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 | |
O processos normalmente tem que trocar informações durante a execução de um programa, e não podem fazer isto através de variáveis compartilhadas. Há algumas classes que permitem a comunicação entre processos filhos e entre o processo pai e seus filhos.
É claro que existe uma comunicação entre pai e filhos que pode ser feita através da passagem de argumentos e retorno de valores de função, mas precisamos de mecanismos mais gerais.
Uma fila (Queue) é uma implementação para multiprocessamento da estrutura de dados sequencial Queue. Basicamente, é uma sacola onde você vai enfiando e tirando coisas, com a garantia de que as coisas saem na mesma ordem que entraram, isto é, o primeiro que entra é o primeiro que sai, bem, como em um fila.
Você pode atribuir um tamanho máximo à fila, ou ela pode crescer indefinidamente, enquanto houver memória. Se a fila estiver cheia, a chamada que adiciona items à fila bloqueia até que haja espaço ou ocorra um timeout.
Um exemplo simples está mostrado a seguir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
Uma fila pode ter vários processos alimentando e vários processos consumindo, e, como estamos no mundo encantado do Python, os objetos podem ser de qualquer tipo, dentro da razoabilidade. Não é uma boa ideia querer enviar um handle de um arquivo aberto de um processo para outro que pode estar em uma outra máquina que sequer tem acesso àquele arquivo, por exemplo.
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 | |
Um problema com o programa acima é que não sabemos a priori quantos items serão colocados na fila. Do jeito que o programa está feito, não podemos apenas verificar se a fila está vazia, pois, como os processos podem ser lançados em qualquer ordem, não é inconcebível que um consumidor teste a fila antes de qualquer produtor colocar alguma coisa dentro. Além disto, podemos dar o azar de que, devido aos atrasos na produção, não haja nada fila, mas os produtores não terminaram ainda. Por isto temos aquele laço infinito na linha 30 do qual só escapamos com um timeout.
Timeouts como agentes de controle do fluxo de execução do programa são normalmente muito questionáveis, devido à dificuldade em se estabelecer valores que não causem paradas falsas ou que não atrasem demais a execução. No entanto, com o que temos até agora, não temos outra alternativa que não complicasse demasiadamente o programa.
Uma primeira ideia seria cada produtor enviar uma “sentinela”, um valor especial que não deve ser processado para avisar que já encerrou a produção. O problema é que o produtor não sabe qual consumidor vai receber aquela sentinela, é possível que um único produtor receba todas e o outro não saiba que os produtores encerraram.
É possível, por exemplo, cada produtor enviar um número de sentinelas igual ao número de consumidores. Cada consumidor lê sentinelas até que o número de sentinelas lidas seja igual ao de produtores, e quando isto acontece, encerra suas atividades.
O programa abaixo implementa isto e também algumas óbvias melhorias em relação ao anterior.
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 | |
Percebam que o programa acima não trata de um dos principais problemas deste tipo de sistema, que é como garantir que todos os consumidores recebam mais ou menos a mesma quantidade de dados. Não há nenhuma garantia de que um dos consumidores não processe todos os dados que foram colocados na fila, por exemplo. Evitar isto é um problema bem mais sofisticado que vamos fingir que não existe, mas que pode ser tratado com a criação de um scheduler que imponha alguma “justiça” na divisão do trabalho. Quem estiver interessado neste assunto deve consultar algum livro sobre implementação de sistemas operacionais.
Devido ao fato de filas serem bem genéricas, existe outra classe de objetos usados para comunicação que é
mais específica, que são os pipes, que, essencialmente, são filas que tem apenas dois processos. Os pipes
podem ser unidirecionais ou bidirecionais, e podem ser criados entre quaisquer dois processos, que podem ser
irmãos ou pai e filho.
Um item é enviado com .send() em uma extremidade e recebido com .recv() na outra. Também existem versões
que enviam buffers de bytes diretamente se este for o seu interesse.
Vamos ver um exemplo bem simples de um pipe em ação.
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 | |
O exemplo abaixo é um pouco mais sofisticado, pois envolve a comunicação bidirecional em um pipe (o que é o default) e a comunicação entro o pais e seus vários filhos.
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 49 50 51 | |
Todas as primitivas de sincronização que vimos para threads também estão disponíveis para o multiprocessamento,
podemos serializar o acesso a um recurso com um Lock(), ou sincronizar os processos com .Barrier(), por exemplo.
O exemplo abaixo mostra como podemos serializar o acesso à escrita de um arquivo. O programa verifica se o arquivo existe e o remove antes de começar a escrever, de forma que sempre é escrito um novo arquivo. É necessária uma certa lógica para garantir que apenas um thread realize esta operação.
É claro que, para este caso específico, seria muito mais simples fazer esta verificação e a remoção do arquivo pré-existente no programa principal. Estamos fazendo a operação dentro dos processos para demonstrar o procedimento de como garantir que apenas um processo, que você não sabe qual é a priori, faça alguma coisa.
Outra alternativa, mais simples do que esta, seria dar nomes conhecidos ao processo, e escolher um deles a priori para fazer o teste de existência e a remoção. Isto ainda necessitaria de uma sincronização, é claro.
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 49 | |