[Tiago Motta] Divagações sobre GIL, threads e IO em python e ruby

Uma coisa que eu sempre me confundi sobre ruby e python é a questão do Global Interpreter Locker (GIL). Na verdade, a dúvida maior é se operações de IO realmente bloqueiam os processos, impedindo a execução de outras threads.

Recentemente li um pouco mais sobre a versão 1.9 do Ruby e como ela passou a utilizar threads do sistema operacional, ao contrário das chamadas Green Threads da versão 1.8. No entanto, o GIL do ruby continua impedindo que duas threads executem ao mesmo tempo. A menos que uma esteja parada executando alguma operação de IO não bloqueante.

No caso do Python, o pouco de informação que tenho me leva a crer que a linguagem utiliza Green Threads. Fica então a minha dúvida se mesmo assim é possivel que o processo execute uma outra thread enquanto aguarda um retorno de IO.

Para começar fiz um pequeno script python para simular uma query pesada do MySql sendo executada 4 vezes. Se o script demorar por volta de dois segundo significa que durante uma operação de IO, o python prosseguiu executando as outras threads:

import _mysql
from threading import Thread

def executa():
    db = _mysql.connect(host=”localhost”,

                        user="root",
                        passwd=”",

                        db="teste")
    db.query(”select sleep(2)”)
    r=db.use_result()
    r.fetch_row()

threads = []
for i in range(4):          
    t = Thread(target=executa, args=())
    t.start()
    threads.append(t)

for t in threads:
    t.join()
 
O resultado da execução pode ser visto abaixo, mostrando que o IO não bloqueou o programa:

> time python teste.py
real 0m2.030s
user 0m0.016s
sys 0m0.012s

Executei o mesmo teste com ruby, com o script similar abaixo:

require 'mysql2'

threads = []
4.times do |i|
    thread = Thread.new do
        my = Mysql2::Client.new(host: “127.0.0.1″, 

                                username: "root", 
                                database: "teste")
        my.query("select sleep(2)").collect{|i|i}
    end
    thread.run
    threads << thread
end

threads.each do |thread|  
    thread.join
end

E o resultado também foi satisfatório:

> time ruby teste.rb
real 0m2.178s
user 0m0.076s
sys 0m0.008s

Ou seja, tanto python como ruby estão lidando bem com execução paralela. Mesmo se não utilizar todos os cores disponiveis, no mínimo o IO para o MySql não está bloqueando.

Com esse bom resultado, resolvi então subir um pouco mais de nível e verificar se colocando o Django na equação poderíamos aproveitar esse bom desempenho. Criei essa pequena view para simular uma query lenta, assim como nos scripts acima, e iniciei o Django (versão 1.3) com o gunicorn.

from django.db import connection
def debug(request):
    cursor = connection.cursor()
    cursor.execute(”select sleep(2)”)
    return HttpResponse(’ok’)

E o resultado, como pode ser visto abaixo, foi ruim:

> ab -n 4 -c 4 http://127.0.0.1:8000/debug/
Time taken for tests:   8.161 seconds

O mesmo teste executado para a dupla ruby on rails tem resultado parecido:

> ab -n 4 -c 4 http://localhost:3000/politicos/
Time taken for tests:   8.043 seconds

Conclusão:

Embora python e ruby permitam IO não bloqueante, os frameworks Django e Rails ainda são bloqueantes. Um dos motivos daqueles memes de “Rails não escala” e “Django não escala”. Felizmente sempre há alternativas.

É possivel escalar via processos como explicado em Solucionando IO bloqueante do mysql para Rails, e utilizando vários workers do gunicorn para Django. Caso seja necessário uma escalabilidade maior ainda, o ideal então é partir para soluções como Event Machine do ruby, GEvent para Python, ou até mesmo NodeJs. Combinando essas soluções com múltiplos processos.