Primeiro vamos criar uma extensão que não libere o GIL para podermos comparar e conferir a melhoria de performance depois. O pivô dessa extensão é a seguinte função:
static int reduce_com_gil(int max, int (*f)(int x, int y)) {
int retorno = 0;
int i;
retorno = (*f)(retorno, i);
}
return retorno;
}
Essa função faz algo similar ao que um reduce faria, mas indo de 0 ao valor indicado em max. Para cada iteração a função enviada como segundo parâmetro é executada. A idéia é causar um grande processamento para que meu core fique travado. O uso dela está descrito no código abaixo:
static PyObject *antigil_calcular_com_gil(PyObject *self) {
int valor = reduce_com_gil(100*1000, *antigil_calculos);
char numero [5000];
sprintf(numero, "%d", valor );
return Py_BuildValue("s", numero);
}
Repare que eu passo para a função reduce_com_gil o ponteiro da função antigil_calculos. Essa outra função faz diversos cálculos a cada iteração do reduce. O nome antigil é o nome da extensão de exemplo. O código completo da extensão pode ser visto aqui.
Instalada a extensão, podemos testar a performance da lib com o seguinte código:
import antigil
antigil.calcular_com_gil()
E o seguinte comando:
$ time python teste.py
real 0m2.702s
user 0m2.692s
Mas o que a gente quer é saber como se comportam as threads. No caso o seguinte script abre 4 threads para aproveitar os 4 cores da minha máquina:
import antigil
from threading import Thread
threads = []
for i in xrange(4):
t = Thread(target=antigil.calcular_com_gil)
t.start()
threads.append(t)
for t in threads:
t.join()
No entando, acaba não aproveitando. Repare que ao executar 4 threads, o GIL age e impede que duas sejam executadas ao mesmo tempo. Dessa forma só um core da máquina é aproveitado. Isso pode ser observado pelo tempo total que é aproximadamente quatro vezes o tempo de execução de uma:
$ time python teste.py
real 0m10.910s
user 0m10.881s
Bom, vamos então à extensão não bloqueante. A chave do sucesso nesse caso são as macros Py_BEGIN_ALLOW_THREADS e Py_END_ALLOW_THREADS que respectivamente liberam o GIL e obtem o GIL de volta. Essas macros estão definidas em Python.h. O código da função reduce ficaria assim:
static int reduce_sem_gil(int max, int (*f)(int x, int y)) {
int retorno = 0;
Py_BEGIN_ALLOW_THREADS
int i;
for(i=0; i < max; i++){
retorno = (*f)(retorno, i);
}
Py_END_ALLOW_THREADS
return retorno;
}
Pronto, basta criar a função antigil_calcular_sem_gil similar à antigil_calcular_com_gil, utilizando a função reduce_sem_gil e então podemos repetir o teste. No caso parametrizei o script de teste para que você possa escolher qual função deseja executar:
import antigil
from threading import Thread
import sys
if 'com-gil' in sys.argv:
calcular = antigil.calcular_com_gil
elif 'sem-gil' in sys.argv:
calcular = antigil.calcular_sem_gil
else:
print "Informar com-gil ou sem-gil"
exit(0)
threads = []
for i in xrange(4):
t = Thread(target=calcular)
t.start()
threads.append(t)
for t in threads:
t.join()
O resultado é bem animador e mostra bem que o código rodou em paralelo:
$ time python teste.py sem-gil
real 0m3.414s
user 0m13.413s
Como o código utiliza apenas CPU, sem IO algum, ao aumentar para 8 threads, mesmo a versão que libera o GIL dobra de tempo pois só tenho disponível 4 cores na minha máquina. No entanto acredito que se fizer alguma operação de IO entre as macros Py_BEGIN_ALLOW_THREADS e Py_END_ALLOW_THREADS outras threads poderão ser executadas no caminho. Isso eu ainda preciso validar.
Só é preciso ter muito cuidado pois código entre essas macros está em território perigoso. A alteração de váriaveis globais ou ponteiros que podem ser compartilhados entre outras threads podem causar erros inesperados a qualquer momento. Portanto, é importante utilizar somente váriaveis locais e dados copiados.
O código completo da extensão em C antigil pode ser visto aqui.