マルチスレッドはてなキーワードしりとりを作ってみました

前回のエントリまでに書いていたはてなキーワードしりとりは

という処理だったのでAPIからのXMLを待つ時間が長くなってしまっていました。
APIに問い合わせて返答を待つ時間はローカルのみで処理するほかの処理と比べて時間がかかるからです。

そこでこれを解消するために一度に複数の単語を入力し、その単語すべてを平行でAPIに問い合わせる仕組みを書いてみました。

上図のようにローカルでの処理に比べて時間のかかるAPIへの問い合わせを平行して行うようにします。

これにより以下の2つの条件が満たされると仮定すれば1単語の場合と同じ待ち時間で複数の単語を処理できるはずです。

  1. 並列化によって新たに増加するローカルでの処理は無視できるくらいに速い
  2. 複数同時にHTTPリクエストを送ってもAPI側での遅延がない、または十分小さい

1については問題ないと思いますが2については何単語問い合わせるのかによると思います。極端に言えば10000語同時にリクエストを送るようなことをすれば遅延どころかはてなのサーバーに負担をかけることになるので今回は最大10単語の並行処理を上限とします。

処理は

  1. ","または"、"で区切って複数単語を入力
  2. 10単語を超えていれば例外としてはじく、またしりとりのルール上同じ単語は2度使えないのでこの時点で入力された単語内に重複があった場合もはじくことにします
  3. 並行処理で各単語のふりがなを取得(ローカルにあればそれを使い、ない場合APIに問い合わせる)
  4. 単語の中にはてなキーワード未登録やxmlを受信できない単語があれば例外処理
  5. 全単語のふりがなが揃うのを待ってしりとりになっているかどうかを確認する
  6. しりとりになっていなければ例外処理
  7. 最初に戻って次の入力を待つ

という流れにします。

多くの機能は従来作っていた単一スレッドのはてなキーワードしりとりと共通なのではてなキーワードしりとりを改造・その7 - 主にプログラムを勉強するブログに載せているコードをshiritori.pyというモジュールとしてインポートして使用するようにしました。

しりとりの特性上、単語が複数入力された場合その順番を並行処理後も維持しておく必要があるためraw_furiganasというリストを使用しています。このリストと単語:ふりがなの対応を保存する辞書はそれぞれのスレッドからアクセスがあるためthreading.Lockとthreading.Semaphoreを使って排他処理としました。どっちもロックでもよかったのですがなんとなく両方使ってみました(^_^;)

# coding: utf-8

import shiritori
import threading
from xml.dom.minidom import parse

class PrlSupportHFD(shiritori.HatenaFuriganaDict):
    """親クラスの辞書登録処理に排他制御を加える"""
    def __init__(self, lock):
        self.lock = lock
        shiritori.HatenaFuriganaDict.__init__(self)
    
    def __missing__(self, word):
        """この辞書に登録されていないwordが参照された場合に呼ばれ
           APIに問い合わせを行い返答する。さらにその結果を辞書に登録する
           親クラスと同機能ですが排他制御を書き加えてあります。
        """
        try:
            dom = parse(self._ask_hatena(word))
        except:
            self.lock.acquire()
            self[word] = False
            self.lock.release()
            return self[word]
        
        items = dom.getElementsByTagName("item")
        hatenaNS = "http://www.hatena.ne.jp/info/xmlns#" #XMLのNS
    
        if items and (items[0].getElementsByTagName("title"))[0].firstChild.data == word:
            kana_data = (items[0].getElementsByTagNameNS(hatenaNS, "furigana"))[0].firstChild.data
        else:
            kana_data = None
        
        self.lock.acquire()
        self[word] = kana_data
        self.lock.release()
        
        return self[word]

class ParallelAsk(threading.Thread):
    """スレッド設定"""
    def __init__(self, index, word, furiganadict, sem, furiganas, counter):
        self.index = index
        self.word = word
        self.furiganadict = furiganadict
        self.sem = sem
        self.furiganas = furiganas
        self.counter = counter
        threading.Thread.__init__(self)
    
    def run(self):
        kana = self.furiganadict[self.word]
        
        self.sem.acquire()
        self.furiganas[self.index] = kana
        self.counter[0] += 1
        self.sem.release()
        
        
        
def multi_input_reader(usedwords, wordsmax=10):
    """入力を受けとり、単語数が最大受付数を超えていないか
       受け付けた単語内ですでに重複していないかを調べる
    """
    wordsstr = raw_input(u"次の単語を入力してください。次は"
                         + str(len(usedwords) + 1)
                         + u"語目です。")
    
    wordsstrU = unicode(wordsstr).replace(u"、", u",")
    
    wordsU = wordsstrU.split(",")
    
    if len(wordsU) > wordsmax:
        raise WordsInputError(value=wordsmax)
    
    if len(wordsU) != len(set(wordsU)):
        raise RepeatedWordsError()
    
    return wordsU

class WordsInputError(shiritori.ShiritoriRuleError):
    def __str__(self):
        return u"入力した単語が多すぎます。一度に入力できるのは" + str(self.value) + "語までです。"

class RepeatedWordsError(shiritori.ShiritoriRuleError):
    def __str__(self):
        return u"入力した単語に重複があります。"

def main():
    WORDS_MAX = 10 # 一度に入力を受け付ける最大単語数
    lock = threading.Lock()
    sem = threading.Semaphore(1)
    furiganadict = PrlSupportHFD(lock)
    usedwords = {}
    
    initial = u"あ" #しりとりの最初の文字
    
    print (u"最初のもじは「" + initial + u"」です")
    
    while True:
        try:
            wordsU = multi_input_reader(usedwords, WORDS_MAX)
        
            res_counter = [0] # ふりがな取得処理が完了したスレッドを数える
            raw_furiganas = [i for i in wordsU]
            
            for i, word in enumerate(wordsU):
                asker = ParallelAsk(i, word, furiganadict, sem, raw_furiganas, res_counter)
                asker.start()
        
            #並行処理中のスレッドすべてが終わるのを待つ
            while True:
                if res_counter[0] == len(wordsU):
                    break
        
            #ふりがなをFuriganaクラスのインスタンス化
            furiganas = []
            for i, word in enumerate(raw_furiganas):
                furiganas.append(shiritori.Furigana(word, wordsU[i]))
            
            for i, word in enumerate(furiganas):
                word.exam(initial, wordsU[i])
                initial = word.creatIL()
                print wordsU[i]
                usedwords[wordsU[i]] = len(usedwords) + 1
        
        except shiritori.CrtShiritoriError ,e:
            print e
            break
        except shiritori.ShiritoriRuleError ,e:
            print e
            continue
        
    print u"終了です"

if __name__ == "__main__":
    main()

実行すると

最初のもじは「あ」です
次の単語を入力してください。次は1語目です。アフロ、ローマ字、ジンクス、スルメ
アフロ
ローマ字
ジンクス
スルメ
次の単語を入力してください。次は5語目です。メール、Ruby,インターフェース
メール
Ruby
「インターフェース」は使えません。次の文字は「び」です!
次の単語を入力してください。次は7語目です。ビデオ、オープンソース、スマホ、北斗の拳
ビデオ
オープンソース
スマホ
「北斗の拳」は「ん」で終わっています。
終了です

となります。