Comparando threads em Python vs. Java - Inf

Propaganda
Comparando threads em Python vs. Java
Bruno Menegola1
1
Instituto de Informática – Universidade Federal do Rio Grande do Sul (UFRGS)
Caixa Postal 15.064 – 91.501-970 – Porto Alegre – RS – Brazil
[email protected]
Abstract. Python é uma linguagem que tem como objetivo criar códigos
de alta legibilidade. Java possui um módulo bastante desenvolvido de threads. Esse módulo será comparado ao módulo de threads de Python. Serão
apresentados aspectos de sintaxe (criação e manipulação), sincronização
e desempenho. Também é discutido as implicações do Global Interpreter
Lock no Python, porque ele ainda está presente e possı́veis soluções para o
problema de multiprocessamento.
1. Introdução
O uso de processamento paralelo em computação já possui uma longa história. Entretanto, nos últimos anos tivemos um barateamento de processadores com mais de
um núcleo, o que torna a possibilidade de múltiplos fluxos de execução em quase
uma necessidade para alcançar toda a demanda de computação requerida. Porém
não só nesses computadores de mesa, ainda temos grandes servidores com inúmeros
processadores e uma grande memória. O uso de threads é especialmente eficiente no
processamento e no desenvolvimento dos sistemas.
Nesse trabalho serão comparados os módulos que permite o uso de threads,
em duas linguagens: Python e Java. O módulo de threads em Python foi inspirado
diretamente pelo módulo de Java. Até mesmo o nome dos métodos era semelhante
em versões iniciais. Além da sintaxe de criação bastante distinta, serão comentados
o que existe ou não em cada linguagem e os seus porquês, quais são os problemas
existentes em Python comparado a Java, porque problemas de desempenho existem
e uma possı́vel solução.
Durante todo o trabalho é feita a comparação com Java, porém focando um
pouco mais em Python, já que é menos conhecido e divulgado. O texto possui algumas regiões parecidas com um tutorial, mas isso é para que o leitor conheça as
possibilidades que o Python provê. Tudo que for apresentado, terá uma comparação
com Java, sempre que possı́vel. Na Seção 2 será discutido aspectos de sintaxe (criação e manipulação de threads) e também métodos de sincronização. Na Seção 3
serão apresentados aspectos de performance e porque eles são desta forma.
2. Sintaxe
Python e Java foram criados em tempos e, principalmente, com objetivos distintos.
Por esse motivo, ambas possuem diferenças gritantes tanto na sintaxe dos recursos
implementados. Java prove meios de criar códigos seguros, com tipagem forte e
estática, colocando uma caracterı́stica importante de linguagens de programação
orientadas a objeto em primeiro plano: o encapsulamento. Python, por sua vez,
foi criado para permitir que os desenvolvedores que a utilizassem pudessem criar
códigos com alta legibilidade e grande poder, mantendo a idéia de codificar menos
e produzir mais.
O Python possui algumas “regras de ouro” que foram escritas pelos seus
criadores e são aconselhadas a serem seguidas por quem utiliza essa linguagem.
Essas regras fazem parte do Zen do Python [Peters 2004] e explicam muitos dos
motivos pelos quais a linguagem é o que é. Duas delas estão citadas abaixo:
“Simples é melhor que complexo.”
“Legibilidade conta.”
Mas tratando da comparação de threads, embora o Python tenha sido influenciado fortemente pelo Java [Python.org 2009d] quando o módulo de thread foi
definido, a sintaxe utilizada para sua criação e manipulação levam em conta essas
regras – entre outros motivos. Possivelmente, Python seja a linguagem em que é
necessário o menor esforço para tratar threads. As suas facilidades serão detalhadas
a seguir, nas próximas subseções.
2.1. Criação
Existem duas formas, em Java, de se criar novas threads: por herança ou interface.
A criação por herança, acredito que seria a forma mais natural de criação. Basta
saber que existe uma classe que implementa a thread e que você quer criar uma nova
que extende essa classe. Entretanto, para resolver o problema de não haver herança
múltipla em Java, são utilizadas as interfaces. Dessa forma é possı́vel implementar
threads com classes que já herdam elementos de outras.
Por outro lado, em Python, também existem duas maneiras de criar threads,
porém elas tem propósitos diferentes. Como dito, a forma mais natural de criação é
por herança. Em Python, um exemplo simples dessa forma está definido abaixo:
1
2
3
4
5
6
7
8
9
10
11
12
from t h r e a d i n g import Thread
from time import s l e e p
c l a s s H e l l o ( Thread ) :
def run ( s e l f ) :
sleep (3)
print ' H e l l o World !
thread = Hello ()
print ' I n i c i a n d o t h r e a d . . .
thread . s t a r t ()
print ' Thread i n i c i a d a . '
'
'
Nesse exemplo, uma classe que extende Thread é definida nas linhas 4-7. A
instância criada, quando iniciada, dorme por 3 segundos, imprime uma string na
tela e finaliza a thread, bem como o programa principal que estava esperando seu
término.
2
Também nesse exemplo já podemos ver como é feita a herança de threads
além de alguns métodos principais (run e start). As opções de criação de threads e
seus métodos principais serão explicados mais adiante.
Comparando com Java, onde eram necessárias interfaces para implementar
threads em classes que já herdavam elementos, Python possui herança múltipla e
isso não é problema. Basta declarar a lista de classes que deseja-se herdar e pronto.
Não vou dar exemplos neste trabalho para não me extender demais com códigos,
mas o formato da declaração pode ser visualizado em [Python.org 2009a]. Claro que
a inexistência de interfaces pode reduzir o encapsulamento, mas isso também não é
o tópico deste trabalho e também não é o objetivo da linguagem.
A outra forma de criação de threads é bastante parecida com a forma implementada em C#. Ela permite que qualquer função ou método de instância ou de
classe seja transformado em uma thread. Um exemplo simples é exibido abaixo:
1
2
3
4
5
6
7
8
9
10
11
12
13
from t h r e a d i n g import Thread
from time import s l e e p
class Hello :
def f o o ( s e l f ) :
sleep (3)
print ' H e l l o World !
'
hello = Hello ()
t h r e a d = Thread ( t a r g e t=h e l l o . f o o )
print ' I n i c i a n d o t h r e a d . . . '
thread . s t a r t ()
print ' Thread i n i c i a d a . '
Esse código produz o mesmo resultado que o exemplo anterior. Porém, a
forma como a thread é instanciada é bastante distinta. Dessa vez, o seu método de
execução principal é o método passado como parâmetro na sua criação (linha 10).
Essa última forma é um grande avanço em relação a Java Threads. Isso torna
extremamente fácil a criação de threads. Entretanto, nem sempre essa forma é recomendada já que pode não ser tão poderosa quanto fazer por herança. Dessa forma
também é preciso ter bastante cuidado com seções crı́ticas, pois com a facilidade
aumentam-se as chances de o desenvolvedor esquecer desses detalhes. Comparando
com Java, a sua classe Thread também possui um parâmetro target, porém deve ser
passado, a esse, um objeto que implementa Runnable. Em Python qualquer objeto
que possa ser invocado pode ser especificado.
Agora que já conhecemos as formas de instanciação. Podemos analisar mais
a fundo as opções de criação. abaixo está o cabeçalho do construtor de threads do
Python:
c l a s s t h r e a d i n g . Thread ( group=None , t a r g e t=None , name=None ,
a r g s =() , kwargs ={})
3
As considerações sobre esse construtor estão a seguir:
ˆ
ˆ
ˆ
ˆ
ˆ
group deve ser None. É um parâmetro reservado para futuras extensões do
Python, quando a classe de agrupamento estiver implementada.
target é um objeto que possa ser invocado pelo método run().
name é o nome da thread.
args e kargs são uma tupla e um dicionário passados como parâmetro de
target.
Se a subclasse sobrescreve o construtor, ela precisa invocar o construtor da
classe base antes de mais nada.
Como dito, Python Threads é fortemente inspirado em Java. Tanto é que
seus parâmetros são bastante parecidos (veja [Sun Microsystems 2008]). O único
que difere é o stackSize que mexe com o tamanho da pilha. Porém isso não é
passado como parâmetro no Python, mas pode ser alterado via uma função do
módulo threading. Quanto aos agrupamentos, Python ainda não implementa essa
parte. Espera-se que no futuro isso seja resolvido.
2.2. Manipulação
Abaixo são listados os principais métodos e atributos para manipulação e controle
de objetos Thread. Após, isso será comparado com Java Threads.
ˆ
ˆ
ˆ
ˆ
ˆ
ˆ
ˆ
run() – método com o laço principal de execução da thread. Esse é o método
que deve ser sobrecarregado ao fazer herança da classe Thread.
start() – inicia a atividade da thread.
join([timeout]) – bloqueia a thread atual e espera até que a outra termine.
Se timeout for especificado, a thread atual fica bloqueada pelo tempo determinado, em segundos (float, pode ser uma fração de segundo), ou até que
a thread termine, o que ocorrer antes. Uma thread não pode esperar por si
mesma, pois isso geraria um deadlock, e nesse caso é levantado uma exceção.
Ergue-se uma exceção também para o caso de um join em uma thread que
ainda não foi iniciada.
name – o nome da thread.
ident – o identificador da thread.
is alive() – verifica se a thread está rodando.
daemon – valor booleano para verificar e setar a thread como deamon. Precisa ser setada antes de iniciar a thread ou gera uma exceção.
Todos os métodos e atributos descritos acima, possuem equivalentes no Java.
Entretanto, o Java possui vários outros além desses, que permite mais controle
sobre os objetos e fluxo de execução. Abaixo são listados os métodos que não
estão presentes no Python e uma breve explicação do porquê ou como produzir um
resultado equivalente ou semelhante.
Segurança
checkAccess()
getDefaultUncaughtExceptionHandler()
getUncaughtExceptionHandler()
setDefaultUncaughtExceptionHandler(...)
4
setUncaughtExceptionHandler(...)
O Python não possui um Security Manager ou algo parecido. O propósito
dessa linguagem não é ser segura em todos os aspectos como o Java. Um
Security Manager dificulta o uso de threads, portanto não foi implementado.
Grupos
enumerate(...)
getThreadGroup()
Python não implementa grupos de threads ainda. Qualquer manipulação
relacionada a isso é inexistente.
Prioridade
getPriority()
setPriority(...)
Por usar threads de núcleo (implementadas usando Pthreads) e nem todos os
sistemas operacionais forem iguais na alteração das permissões, Python não
permite alterar esse parâmetro. Embora em Unix seja possı́vel alterar o parâmetro nice da thread/processo que é usado no escalonador para determinar
as prioridades. Essa função está disponı́vel no módulo os do Python.
Estados
getState()
destroy() (deprecated)
resume() (deprecated)
stop() (deprecated)
suspend() (deprecated)
holdsLock(...)
Na documentação do Python nada é falado sobre estados das threads. No
Java eles são bem definidos e são 6, exatamente. Talvez na representação
interna dp Python seja utilizado algo parecido, mas para o usuário isso é
transparente. Quanto aos métodos deprecated do Java para interrupção e
resumo do fluxo, o Python já não implementou desde o inı́cio pelo motivo
que o Java explicita em sua documentação: isso não é seguro; e pois esses
métodos tornariam obscuras as formas de término de uma thread, algo que o
Python não permite já pela sua filosofia: “Explı́cito é melhor que implı́cito”
(do Zen do Python).
Quanto ao holdsLock(...), Python não possui monitores (será melhor explicado na Seção 2.3) e não tem como fornecer essa informação.
Troca de contexto
sleep(...)
yield()
Ambos métodos não estão presentes na classe Thread do Python. O sleep
5
pode ser usado através da função sleep do módulo time. Terá o mesmo efeito
pois as threads do Python são implementadas com Pthreads, como dito, e
essa função permite ao escalonador do sistema liberar outra thread. O yield
pode ser simulado com um time.sleep(0).
2.3. Sincronização
A forma clássica de sincronização em Java é usar monitores. Mascarado pelo modificador syncronized, os monitores organizam o acesso a blocos de comandos pelas threads concorrentes. Embora isso traga grande facilidade e segurança para o
programador, nem sempre é possı́vel resolver todos os problemas de forma trivial
utilizando esse método. Um exemplo disso é quando é preciso adquirir acesso a uma
seção crı́tica em um método e liberá-la em outro.
Por essas dificuldades e pela descoberta de novos operadores que facilitariam a sincronização de threads, nas versões mais novas, o Java disponibiliza outras
ferramentas: locks, semáforos, barreiras, trancas, exchangers, etc.
O Python não implementa monitores – sinceramente, nunca encontrei uma
explicação oficial de porque não – mas possui alguns recursos de sincronização:
variáveis de condição, eventos, locks e semáforos.
A utilização desses objetos são basicamente iguais em ambas linguagens, para
os casos em que existem em ambas linguagens. Python não possui recursos mais
complexos como barreiras, trancas ou exchangers. Mas eles podem ser implementados com as classes disponı́veis. Utilizando as primitivas disponı́veis também não é
possı́vel garantir ordem como pode ser feito em monitores do Java.
Como o leitor já deve ter percebido, as threads de python constituem um
módulo ainda inacabado. Foram implementados os recursos básicos para que qualquer outro pudesse existir posteriormente. Um caso é o dos monitores: existem
várias implementações na web para solucionar esse problema. Elas fazem uso de
anotações, disponı́veis também no Python, e da instrução with para usar em blocos
de comandos.
Deixando as implementações de terceiros de lado, é possı́vel fazer algo parecido com monitores para blocos de comandos com essa instrução with. Veja o
exemplo abaixo:
1
2
3
4
5
6
7
8
9
10
11
12
from t h r e a d i n g import Thread , Lock
from time import s l e e p
def f o o ( i d ) :
with l o c k :
for i in xrange ( 5 ) :
print i d
sleep (0.1)
l o c k = Lock ( )
t 1 = Thread ( t a r g e t=foo , a r g s =(1 ,) )
t 2 = Thread ( t a r g e t=foo , a r g s =(2 ,) )
6
13 t 1 . s t a r t ( )
14 t 2 . s t a r t ( )
Esse exemplo cria duas threads em que cada uma imprime seu id e dorme por
100ms, repetindo isso por 5 vezes. Se for desconsiderado o uso do Lock, as threads
poderiam imprimir em qualquer ordem. Com o uso do Lock, tudo que está dentro
do bloco onde ele é utilizado é uma seção crı́tica. Quando essa seção terminar o Lock
é liberado. Dessa forma, no exemplo acima, cada thread executa suas operações de
uma vez e libera a seção para a outra.
A instrução with pode ser usada com locks, semáforos ou variáveis de condição. Qualquer outra implementação de classes de sincronização deve definir um
Context Manager (não será explicado neste trabalho) para ser usado nesse tipo de
instrução.
3. Desempenho
Embora o Python tenha a sintaxe simples de criação de threads, permitir criálas a partir de qualquer objeto que possa ser invocado e possuir os elementos de
sincronização básicos e suficientes para realizar qualquer tarefa, o desempenho não
é o esperado, como veremos. O Python possui um problema extraordinário que está
ligado diretamente ao Global Interpreter Lock ou apenas GIL que será explicado a
seguir.
Antes de mais nada, podemos constatar o problema na prática e vou demonstrar um exemplo simples de como observá-lo. Veja o código a seguir:
1
2
3
4
5
6
7
8
9
10
11
12
13
from t h r e a d i n g import Thread
from time import s l e e p
def buzy ( ) :
for i in xrange ( 1 6 0 0 0 0 0 0 0 0 ) :
pass
t 1 = Thread ( t a r g e t=buzy )
t 2 = Thread ( t a r g e t=buzy )
t1 . s t a r t ( )
sleep (30)
t2 . s t a r t ( )
Esse programa inicia duas threads, com um intervalo de 30s entre as partidas,
que apenas executam uma contagem até um número razoável. Isso serve apenas para
ocupar o processador.
A partir desse código, foi rodado um experimento em um computador com
processador Intel Core 2 Duo 2.4 GHz 2MB Cache L2, 2GB RAM 666MHz e rodando
Linux 2.6.28. Como o processador possui dois núcleos, e as threads de Python são
implementadas com Pthreads (threads de núcleo no Linux), esperava-se que cada
7
thread ocupasse em torno de 100% de cada núcleo. Entretanto os resultados foram
como os exibidos na Figura 1.
Figura 1. Resultado de teste de funcionamento de 2 threads em Python em um processador
de dois núcleos.
Como o processador é de dois núcleos, o valor esperado de uso de CPU para
cada thread era de 100%, totalizando os 200% disponı́vel no processador. Porém a
soma manteve-se em torno de 100% e cada thread convergiu para algo em torno de
50% de uso. Embora o escalonador do sistema pareça ser conservativo – motivo pelo
qual não ouve uma queda brusca quando outra thread entrou em concorrência – o
problema pode ser constatado. Isso tudo deve-se ao GIL e como as threads foram
implementadas no interpretador da linguagem.
3.1. Global Interpreter Lock
Traduzido de [Python.org 2009b]:
O interpretador Python não é totalmente thread safe. A fim de
suportar programas multithread, existe um lock global, chamado global interpreter lock ou GIL, que precisa ser retido pela thread atual
antes que ela possa acessar os demais objetos do Python. Sem o lock,
mesmo as operações mais simples poderiam causar problemas em um
programa multithread: por exemplo, quando duas threads simultaneamente incrementam o contador de referência do mesmo objeto, o
contador poderia acabar sendo incrementado apenas uma vez ao invés
de duas.
8
Basicamente, para suportar programas multithread, o interpretador libera e
prende o lock a cada 100 instruções de bytecode. O lock também é liberado e preso
em torno de operações de IO potencialmente bloqueantes.
O GIL foi necessário de acordo como o interpretador já estava implementado
na época em que o módulo de threads foi introduzido. É um problema de difı́cil
solução sem que o interpretador seja inteiramente reescrito.
3.2. O módulo multiprocessing
O leitor deve-se perguntar agora como podemos criar programas que fazem bom
uso de múltiplos processadores/núcleos. Simples: criamos novos processos. Um
processo novo possui região de memória distinta e trata objetos distintos. O GIL
não é problema nesse caso. Porém, perde-se grande desempenho devido as trocas
de mensagem entre processos, além do código ficar mais complicado. Como para a
questão de desempenho ainda não há solução perfeita e a criação de novos processos
é um preço a se pagar, foi criado um módulo que auxilie nessa criação e na troca de
dados entre eles. Esse módulo foi chamado de multiprocessing [Python.org 2009c].
Basicamente ele provê métodos de criação, acesso e controle de subprocessos,
além de métodos de troca de objetos e o mais importante: sincronização. As mesmas
primitivas de sincronização para threads estão disponı́veis para subprocessos. Além
de sincronização é possı́vel criar pools de processos – em Java isso é disponı́vel para
threads, em Python é inexistente nesse caso – e managers para controlar acesso a
memória.
4. O que falta?
Definitivamente, livrar-se do GIL. Porém essa é uma tarefa complicada. Já foram
propostas soluções mas normalmente elas provocam queda de desempenho em programas de uma única thread. Então, optou-se por não perder nesses casos, já que a
grande maioria dos programas no mundo é feita com uma única thread.
No caso de não conseguir-se remover o GIL sem perder desempenho para
programas single thread, nem ter que reescrever o interpretador novamente, ao menos
reduzir o problema seria um bom começo. Para isso já se discute soluções.
Além desse problema, é preciso concluir o desenvolvimento do módulo threading, implementando grupos e quem sabe outras primitivas de sincronização e
além.
5. Conclusões
O Python com certeza mantém o seu ponto, que consiste em criar uma linguagem
com alta legibilidade e de sintaxe simples. A criação e manipulação de threads é
bastante simples, como foi visto. Embora, na minha opinião, poderiam haver mais
alguns elementos simples – e que não consumiriam muito tempo de desenvolvimento
da linguagem – como, por exemplo: monitores, barreiras, trancas e etc. Embora já
existam implementações disponı́veis na internet, seria interessante colocá-las diretamente na linguagem.
Quando comparamos o desempenho, vimos que o Java se sai melhor nesse
ponto. O futuro é remover o GIL do Python. Mas uma redução do problema já seria
9
um grande passo. Talvez o GIL desse até mesmo um bom estudo para trabalhos de
conclusão em Sistemas Operacionais ou Compiladores.
Embora o desempenho seja ruim ainda há o que considerar, como se é possı́vel
fazer uso de múltiplos processos. Pois, por exemplo, um programa que não troca
muitas mensagens entre eles não terá grande overhead de comunicação.
Enfim, eu sempre fui a favor de deixar filosofias e crenças de lado e realmente
escolher a linguagem certa para cada problema. No caso de threads ambas linguagens
comparadas tem seus pontos fortes. É preciso definir quais deles são essenciais para
um projeto na hora de escolher.
Referências
Peters, T. (2004). Pep 20 – the zen of python. http://www.python.org/dev/peps/
pep-0020. [Online; accessed 22-June-2009].
Python.org (2009a). Classes. http://docs.python.org/tutorial/classes.html.
[Online; accessed 20-June-2009].
Python.org (2009b). Initialization, finalization, and threads. http://docs.python.
org/c-api/init.html. [Online; accessed 21-June-2009].
Python.org (2009c). multiprocessing – process-based “threading” interface. http://
docs.python.org/library/multiprocessing.html. [Online; accessed 20-June2009].
Python.org (2009d). threading – higher-level threading interface. http://docs.
python.org/library/threading.html. [Online; accessed 19-June-2009].
Sun Microsystems (2008). Class thread. http://java.sun.com/javase/6/docs/
api/java/lang/Thread.html. [Online; accessed 19-June-2009].
10
Download