2006年1月26日

Ruby, Pythonで並列に逆引きを行う

ウェブサーバのログを解析するときなど、IPアドレスからホスト名を引く処理 (逆引き) を大量に行いたいときがあります。DNS への逆引きの問い合わせには場合によっては数秒待たされることがあるため、大量の IP アドレスをひとつづつ順に処理していくとかなり時間がかかります。

本記事では Ruby または Python でマルチスレッドで並列に逆引きを行う方法を紹介します。

 

Ruby の場合

Ruby で逆引きを行うには socket ライブラリを使う方法と、Resolv ライブラリを使う方法があります。マルチスレッドで並列に逆引きを行うには Resolv ライブラリを使う必要があります。

socket ライブラリを使った場合、 Socket.gethostbyaddr か Socket.getaddrinfo を使って逆引きを行います。これらは同名の C の関数を内部的に呼びます。逆引きには数秒間かかることがありますが、Ruby のスレッドはユーザレベルスレッドであるため、C の関数を呼んでいる間は他のスレッドはまったく動きません。このため、マルチスレッドで並列に逆引きしようとしても実際には逐次的に行われてしまいます。

一方、 resolv ライブラリでは gethostbyaddr などに相当する部分が Ruby で書かれているため、Cの関数呼び出しで長時間ブロックされることなしに、並列に逆引きを行えます。

次のプログラムは Socket.gethostbyaddr を使った場合と Resolv.getname を切り替えられるようになっています。

require 'thread'
require 'thwait'
require 'socket'
require 'resolv'

def lookup_with_gethostbyaddr(ip_address)
  addr = ip_address.split('.').map {|x| x.to_i}.pack('C4')
  return Socket.gethostbyaddr(addr).first
rescue SocketError
  return ip_address
end

def lookup_with_resolv(ip_address)
  return Resolv.getname(ip_address)
rescue Resolv::ResolvError
  return ip_address
end

def usage
  puts "mt-resolver.rb METHOD NUM-THREADS FILE"
  exit
end

def main
  usage unless ARGV.length == 3
  lookup_method = ARGV[0]
  num_threads = ARGV[1].to_i
  file_name = ARGV[2]
  queue = Queue.new
  File.readlines(file_name).map {|line|
    ip_address = line.chomp
    queue.push(ip_address)
  }

  mutex = Mutex.new
  threads = []
  num_threads.times {
    queue.push(nil)
    thread = Thread.new {
      while ip_address = queue.pop
        host_name = send(lookup_method, ip_address)
        mutex.synchronize {
          puts host_name
        }
      end
    }
    thread.abort_on_exception = true
    threads.push(thread)
  }
  ThreadsWait::all_waits(*threads)
end

main

DNSのキャッシュが影響するため、速度を比較するのは難しいのですが、無作為にサンプリングした 100 の IP アドレスのリストを 2つ作り (重複するアドレスなし)、それぞれ lookup_with_gethostbyaddr を使った場合と lookup_with_resolv を使った場合で時間を計測してみました。

% time ruby mt-resolver.rb lookup_with_gethostbyaddr 100 list1 > /dev/null
670.04s total : 0.05s user 0.01s system 0% cpu
% time ruby mt-resolver.rb lookup_with_resolv 100 list2 > /dev/null
130.20s total : 0.60s user 0.06s system 0% cpu

Socket.gethostbyaddr では 670秒かかりましたが、 Resolv.getname では 130 秒で終わりました。list1, list2 では重複しない IP アドレスを使っているため、DNSのキャッシュの影響はあまりないと思います。

Python の場合

Python のスレッドは OS のネイティブスレッド (pthreads) であるため、socket.gethostbyaddr を呼んでもブロックすることはありません。

import threading
import Queue
import socket
import sys
import fileinput

def lookup(ip_address):
    try:
        return socket.gethostbyaddr(ip_address)[0]
    except socket.herror:
        return ip_address

def resolver(queue, lock):
    while True:
        ip_address = queue.get()
        if ip_address == None:
            break
        host_name = lookup(ip_address)
        lock.acquire()
        try:
            print host_name
        finally:
            lock.release()

def main():
    queue = Queue.Queue()
    num_threads = int(sys.argv[1])
    for line in fileinput.input(sys.argv[2]):
        ip_address = line.strip()
        queue.put(ip_address)
    lock = threading.Lock()
    for i in xrange(num_threads):
        queue.put(None)
        thread = threading.Thread(target = resolver, args = (queue, lock))
        thread.start()

main()

ふたたび無作為にサンプリングした 100 の IP アドレスのリストを 2つ作り (重複するアドレスなし)、それぞれスレッドの数が 1の場合と 100 の場合で時間を計測してみました。

% time python mt-resolver.py 1 list3
584.74s total : 0.02s user 0.03s system 0% cpu
% time python mt-resolver.py 100 list4
20.07s total : 0.10s user 0.09s system 0% cpu

スレッドの数が 1の場合は Ruby で gethostbyaddr を使ったときとそれほど変わらず 584 秒かかりましたが、スレッドを 100 にすると、20秒で終わりました。こちらも list3, list4 で重複しない IP アドレスを使っています。

まとめ

Ruby または Python を使ってマルチスレッドで並列に逆引きを行う方法を紹介しました。元々は Ruby でも resolv ライブラリを使えば Python と同程度の速さで並列に逆引きできると予想していたのですが、やってみると、Python 版の方が速いという結果になりました。今回のようなケースではネイティブスレッドが有効に働くようです。