domingo, 22 de julho de 2012

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.

segunda-feira, 9 de julho de 2012

Estratégias de persitência do Redis

Nestes ultimos meses eu ministrei algumas palestras sobre o uso de redis na prática, usando como exemplo algumas funcionalidades do Musica.com.br. Uma das dúvidas mais constantes nesses eventos é a questão das opções de persistência deste banco. Confesso que não havia estudado o suficiente sobre o assunto. Eis que com o crescimento do site, surgiu a necessidade de uma estratégia melhor sobre a manutenção desses dados. Então aproveitei minhas horas em aeroportos para realizar alguns testes e responder algumas dúvidas.

Pra começar o Redis possui três formas de persitência de dados: não persistir, dump no disco e append only. O dump no disco, basicamente consiste em gravar uma cópia da memória em disco de tempos em tempos. O append only grava todos os comandos em um log, de forma que para recuperar em caso de restarte ele refaz todo o caminho percorrido.

Vamos então às perguntas que eu me fazia, e as respostas que obtive com meus testes:

1) É possível converter o rdb (dump) para um aof (appendonly)?

Sim para isso é preciso executar o comando BGREWRITEAOF estopar o redis server e estartar de novo com a nova configuração habilitada para appendonly.

2) O tempo para gravar e carregar o rdb (dump) é grande?

Para os padrões do Redis gravar é sim um tempo grande. Mas talvez não seja algo que possa atrapalhar. Depende muito da quantidade de dados que sua base terá. Fiz um teste adicionando três vezes 1 milhão de registros e comparando o tempo que demorava para gerar o dump em disco. Essa tabela pode servir de guia para saber quando é o momento de colocar o seu redis em uma máquina separada, e quando é hora de trocar de estratégia de gravação.

RegistrosMemóriaDiscoTempo para salvarGravação de Memória/segundo
1.000.00099.94M24M0.56s178.46 M/s
2.000.000191.50M42M0.93s205.37 M/s
3.000.000283.03M62M1.32s214.41 M/s

No entanto, o load do dump com 3 milhões de registros foi irrisório.

3) O tempo para carregar o aof (appendonly) é grande?

Sim, muito grande. Fiz um teste similar ao do dump, com 3 milhões de registros colocando 313.57M em memória e gerando um arquivo aof de 192M. Ao restartar o servidor ele demorou 4 segundos para carregar o arquivo. Um resultado que achei péssimo. No entanto, se pensarmos que restartar servidor é algo que não faremos com tanta constância, pode não ser algo tão ruim. Só deve-se ficar em mente que manutenções desse tipo em uma base grande devem ser em horários com pouco ou nenhum uso do seu servidor master.

4) É possivel compactar o aof (appendonly) com BGREWRITEAOF?

Um dos problemas do aof é que se um registro for incluido e removido, as duas instruções serão gravadas, de forma que não há uma razão clara entre memória do servidor e tamanho do arquivo em disco gerado. Minha dúvida era se eu poderia limpar o aof reescrevendo somente aquilo que estava na memória. E sim, isso é possível com o comando BGREWRITEAOF.

5) Os comandos ficam mais lentos com aof (appendonly) ligado?

Sim. Fiz um teste com concorrência. Três processos inserindo 1 milão de itens em listas diferentes. Com rdb (dump) habilitado a média de tempo foi de 0.33s. Com aof (appendonly) habilitado e rdb (dump) desabilitado a média de tempo foi de 0.43s. Essa proporção de 30% mais demorado para aof foi constante nos três testes seguintes que fiz como tira-teima.

6) Se o redis slave ficar down por um tempo ele recebe os dados do master?

Sim, fiz uma série de testes e percebi que o redis slave ignora os arquivos salvos por ele mesmo. Sempre que se inicia ele recebe todos os dados novamente do master, seja ele configurado como rdb ou como aof. Portanto, se estiver utilizando master slave, uma boa é configurar o seu slave para não gravar nada.

7) Gravar o rdb (dump) afeta a performance na resposta a outros comandos?

Sim, fiz um teste gravando três vezes uma memória de 2GB enquanto estava inserindo 1 milhão de novos registros. A insersão deles demorou 25% mais que inserir a mesma quantidade de registros sem salvar nenhuma vez o rdb. Só para se ter uma idéia, o tempo de gravação deste dump foi de 9 segundos e o tempo de load foi de 4 segundos.

Conclusão:

- Manter seus dados com rdb deixa o redis mais rápido, mas pode exigir muito de IO nos intervalos de persistência. Se a memória estiver muito grande, vale a pena colocar o servidor master em uma máquina dedicada para que não atrapalhe outros serviços.

- Manter seus dados com aof deixa o redis mais lento, mas garante que nenhuma transação será perdida. É preciso ficar atento para o arquivo gerado e periodicamente compactar ele com BGREWRITEAOF. Também tomar cuidado nos restarts que podem demorar.

quarta-feira, 2 de maio de 2012

Bate-papo sobre o Redis no 3o Dev in Santos

Neste sábado dia 5 de Maio participarei do maior encontro de desenvolvedores da baixada santista, falando um pouco sobre o Redis, e como o utilizamos pra tornar o desenvolvimento do Musica.com.br mais fácil, estável e seguro.

O programa completo do Dev in Santos com todas as apresentações você pode ver aqui: http://www.mktvirtual.com.br/mailing/2012/dev-in-santos/. Repare que teremos ótimas apresentações sobre uma grande variedade de assuntos como NodeJS, Python, IOS, Unity3D e é claro, Redis.

Se você estiver pela baixada santista, ou até mesmo em São Paulo neste fim de semana, será uma bela oportunidade para bater um papo sobre desenvolvimento e outras nerdices. Não deixe de se inscrever enquanto ainda há vagas.

Vejo vocês lá.

segunda-feira, 23 de abril de 2012

Palestras no Flisol de Salvador

Este sábado, dia 28 de Abril estarei presente no Flisol, Festival Latino-Americano de Instalação de Software livre, conversando um pouco sobre como nginx e algumas técnicas de otimização para web. Serão duas palestras que envolvem o mesmo tema:

Na primeira debaterei com o público sobre algumas ferramentas utilizadas no Musica.com.br para tornar o portal mais dinâmico e rápido, sem sobrecarregar o servidor. Bibliotecas como Head.js, backbone.js e localStorage serão abordadas.

A segunda falaremos sobre como escalar seu site com Nginx. Mostrarei alguns problemas que tivemos e como solucionamos eles em diversos produtos da Globo.com.

A programação completa do Flisol Salvador você confere aqui: http://softwarelivre.org/flisol-ssa/programacao.

Portanto, se estiver por salvador neste feriadão, não deixe de participar deste evento e puxar um papo comigo.

terça-feira, 22 de março de 2011

Corrigindo o enconding retornado pelo Net::HTTP do ruby 1.9

A String retornada pelo Net::HTTP.get no ruby 1.9 sempre é definida com encoding "ASCII-8BIT". Acontece que muitas vezes esse não é o encoding correto da String, e portanto, erros esquisitos podem acontecer. Um exemplo pode ser visto abaixo quando utilizei essa String em um erb:

invalid byte sequence in UTF-8

Ou o seguinte, que acontece ao tentar encodar essa String para UTF-8:

> minha_string.encode("UTF-8")
Encoding::UndefinedConversionError: "\xC3" from ASCII-8BIT to UTF-8

A solução para este problema é antes de encodar, definir o encoding correto da String. Para isso podemos utilizar o header "content-type" retornado na requisição. Se por exemplo ele retornasse 'text/xml; charset=ISO-8859-1', podemos converter a String da seguinte forma:

> minha_string.force_encoding("ISO-8859-1").encode("UTF-8")

Para não ter que fazer isso a cada requisição HTTP, criei uma lib que altera o metodo body do HttpResponse. O código pode ser pego aqui:

http://gist.github.com/882465

Eu até pensei em alterar o metodo force_encoding da String para aceitar o valor do content-type, mas achei que isso seria dar muita responsabilidade para a String. Como o valor de content-type está apenas no ambito do HTTP, faz sentido colocar na classe Net::HttpResponse.

quarta-feira, 3 de novembro de 2010

Gem brazilian-rails em versões de rails antigas

Se você estiver usando uma versão antiga do brazilian-rails é bem provável que ao tentar instalar esta gem ela comece a instalar também versões superiores do rails. Isso porque esta gem possui um conjunto de dependências definidas somente com o operador ">=".

Ou seja, se você tentar instalar a versão 2.1.13 por exemplo, que funciona com o rails 2.3.2, ele tentará baixar a dependência da gem brdinheiro ">= 2.1.13", acabando por baixar a versão 3.0.0, que depende do activerecord 3.0.0, do rails 3. Nem preciso dizer que isso acabará dando merda.

A solução é baixar todos os componentes independentemente sem suas dependências da seguinte forma:

sudo gem install brnumeros --version=2.1.13 --ignore-dependencies
sudo gem install brdinheiro --version=2.1.13 --ignore-dependencies
sudo gem install brcep --version=2.1.13 --ignore-dependencies
sudo gem install brdata --version=2.1.13 --ignore-dependencies
sudo gem install brhelper --version=2.1.13 --ignore-dependencies
sudo gem install brstring --version=2.1.13 --ignore-dependencies
sudo gem install brcpfcnpj --version=2.1.13 --ignore-dependencies
sudo gem install brI18n --version=2.1.13 --ignore-dependencies
sudo gem install brazilian-rails --version=2.1.13 --ignore-dependencies

quinta-feira, 2 de setembro de 2010

Solucionando IO bloqueante do mysql no ruby

Fui a uma palestra muito interessante sobre performance na Oscon. A palestra No Callbacks, No Threads: Async & Cooperative Web Servers with Ruby 1.9, tratava do problema de IO bloqueante do driver do mysql para ruby, e como solução era proposto o uso de recursos como Event Machine e Fibers do ruby 1.9. Contudo, a solução não ficava nada elegante, tornando a manutenção do código muito complicada. Embora hoje haja um esforço para tornar esse trabalho transparente, consegui obter o mesmo resultado em performance basicamente aumentando o número de processos a atenderem as requisições.

Mas antes de explicar a solução é preciso demonstrar o problema. O caso é que embora tenhamos threads no ruby, alguns drivers como o do mysql são bloqueantes, ou seja, quando estão em uma operação de IO eles bloqueiam o processo inteiro, inclusive todas suas threads. Veja por exemplo o seguinte código:

class TestesController < ApplicationController
def index
Thread.new { Teste.connection.execute("insert into testes (id) select sleep(2)") }
render :text => 'ok'
end
end

Teoricamente ao fazermos a requisição a este controller de teste, a requisição não deveria durar os dois segundos de espera pelo retorno do insert ao mysql. Mas não é isso que acontece. As requisições acabam sendo enfileiradas pois o processo inteiro fica bloqueado a cada execução de query no banco. Isso pode ser comprovado utilizando o Apache Benchmark.

> ab -c 10 -n 10 http://localhost:3000/testes
...
Concurrency Level: 10
Time taken for tests: 20.514 seconds
Complete requests: 10
...

É bom deixar claro que o bloqueio ocorre somente ao usar o método execute do driver, que é responsável por fazer atualizações no banco. Fazendo somente consultas, o bloqueio não ocorre.

Na palestra em questão, foi demonstrado que utilizando Event Machine e Fibers é possivel desbloquear o processo utilizando callbacks. No final o tempo total foi reduzido para 2 segundos e alguns milésimos. Esse mesmo resultado eu obtive configurando um nginx com passenger configurado com 10 forks. Uma solução bem mais limpa. São apenas dois parametros, um do nginx, e outro do passenger:

worker_processes  10;
#...
http {
passenger_max_pool_size 10;
}

E o resultado do teste:

> ab -c 10 -n 10 http://localhost/testes

Concurrency Level: 10
Time taken for tests: 2.424 seconds
Complete requests: 10

É claro que ainda sim a solução proposta na palestra é mais performática, até porque nela o consumo de memória é bem menor. Resta saber se essa economia vale a pena quando se pesa na balança o custo de manter um código mais complicado e os problemas que a concorrência podem trazer ao seu projeto. E para demonstrar o quão escalável é dividir as requisições em processos, fiz ainda um ultimo teste, com 1000 requisições, sendo 200 simultâneas, configurando o nginx e o passenger para trabalhar com 200 forks:

> ab -c 200 -n 1000 http://localhost/testes

Concurrency Level: 200
Time taken for tests: 13.043 seconds
Complete requests: 1000

Ou seja, o tempo total de teste manteve-se estável. Levando- em conta que dificilmente alguém fará um insert no banco com sleep, acredito que esssa seja uma boa solução para o problema de IO bloqueante. Ao invés de threads, utilizar processos.