試験運用中なLinux備忘録・旧記事

はてなダイアリーで公開していた2007年5月-2015年3月の記事を保存しています。

PyGTKで重い処理をしているときにGUIを固まらせないための手法をまとめる

これまでに書いたPyGTKのコードの中で、何か重い処理をさせているときに(メインループに処理が回らないことにより)GUIが固まってしまうのを避けるために幾つかの方法を使用してきた。ここではそれらをまとめておく。

スレッドを使用する

マルチスレッド化して重い処理を別のスレッドで実行するようにする。子プロセスの実行など、同時に複数の処理を行いたいことがあるときに使えるが、以下のデメリットがある。どうしてもスレッドを使用しなくてはならない、という場合以外はおすすめできない。

  • スレッドを使用するための初期化処理としてgtk.gdk.threads_init()gtk.main()よりも先に呼ぶ必要がある他、別スレッド側がGUI部品をいじるときにはメインスレッド側と同時にいじろうとして落ちる*1ということを避けるためにgtk.gdk.threads_enter()gtk.gdk.threads_leave()の間に記述する必要がある
  • マルチスレッド化の処理コストがかかる(CPUが行う合計の仕事量が増える)上に上の「ロック」処理も必要なので、パフォーマンス的に不利*2

ジェネレータとGLibのidle_add()を使用する

PyGTKでのプログレスバーについて(シンプルなGUIダウンローダを作成)」で使用している。
glib.idle_add()*3はメインループよりも低い優先度で処理を実行し、その戻り値がTrueであれば繰り返し実行される。
ジェネレータの中で重いループなどの処理を行い

  • ループの中などで処理を続行するところ: yield True
  • 処理終了もしくは停止するところ: yield False

を記述するようにして

g = [ジェネレータ名]([引数...])
glib.idle_add(g.next)

のようにすると、その中で重い処理が行われてもGUIが固まることはない。ただし、きちんと「yield True」が途中で繰り返し呼ばれるようになっていないとうまくいかない。

手動でメインループを回す

PyGTKでダイアログを出しつつ親ウィンドウも操作できるようにする」でも使用しているが、gtk.main_iteration()はメインループを1回だけ回す。gtk.events_pending()がTrueのときにこれを繰り返し呼ぶようにすると、それまでにGUI部品に対して行った操作の内、まだ反映処理がされていないものを処理してくれる。

(GUI部品に対する操作を記述...)

while gtk.events_pending():
  gtk.main_iteration()

これを使用することによっても、スレッドを使用せずにGUIが固まるのを防ぐことができる場合がある。例えば「ドメインの名前解決チェックを行うGUIツールをPyGTKで作成」のコードをシングルスレッドで記述すると下のようになる。一部スリープ時間などは調整している。

#! /usr/bin/python
# -*- encoding: utf-8 -*-

# Filter2 20090422(single-threaded version) (C) 2009 kakurasan
# Licensed under GPL-3


import socket
import time
import sys
try:
  import pygtk
  pygtk.require('2.0')
except:
  pass
try:
  import pango
  import gtk
except:
  print >> sys.stderr, 'Error: PyGTK is not installed'
  sys.exit(1)
try:
  from glib import timeout_add as glib_timeout_add
  from glib import source_remove as glib_source_remove
except:
  try:
    from gobject import timeout_add as glib_timeout_add
    from gobject import source_remove as glib_source_remove
  except:
    print >> sys.stderr, 'Error: cannot import GLib functions'
    sys.exit(1)


class MainWindow(gtk.Window):
  """
  メインウィンドウ
  """
  def __init__(self, *args, **kwargs):
    gtk.Window.__init__(self, *args, **kwargs)
    # ショートカットキー(アクセラレータ)
    self.accelgroup = gtk.AccelGroup()
    self.add_accel_group(self.accelgroup)
    # メニュー項目
    self.item_quit = gtk.ImageMenuItem(gtk.STOCK_QUIT, self.accelgroup)
    self.menu_file = gtk.Menu()
    self.menu_file.add(self.item_quit)
    self.item_file = gtk.MenuItem('_File', True)
    self.item_file.set_submenu(self.menu_file)
    self.menubar = gtk.MenuBar()
    self.menubar.append(self.item_file)
    # 部品
    self.textview_input = gtk.TextView()
    self.textview_found = gtk.TextView()
    self.textview_notfound = gtk.TextView()
    self.textview_skipped = gtk.TextView()
    for textview in (self.textview_input, self.textview_found, self.textview_notfound, self.textview_skipped):
      textview.set_wrap_mode(gtk.WRAP_CHAR)
    self.textbuf_input = self.textview_input.get_buffer()
    self.textbuf_found = self.textview_found.get_buffer()
    self.textbuf_notfound = self.textview_notfound.get_buffer()
    self.textbuf_skipped = self.textview_skipped.get_buffer()
    self.txttag_active = gtk.TextTag()
    self.txttag_active.set_property('foreground', 'blue')
    self.txttag_active.set_property('weight', pango.WEIGHT_BOLD)
    self.textbuf_input.get_tag_table().add(self.txttag_active)
    self.id_textbuf_input = self.textbuf_input.connect('changed', self.on_textbuf_changed)
    self.sw_input = gtk.ScrolledWindow()
    self.sw_found = gtk.ScrolledWindow()
    self.sw_notfound = gtk.ScrolledWindow()
    self.sw_skipped = gtk.ScrolledWindow()
    for sw in (self.sw_input, self.sw_found, self.sw_notfound, self.sw_skipped):
      sw.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
    self.sw_input.add(self.textview_input)
    self.sw_found.add(self.textview_found)
    self.sw_notfound.add(self.textview_notfound)
    self.sw_skipped.add(self.textview_skipped)
    self.button_start = gtk.Button(label='_Check')
    self.button_start.set_image(gtk.image_new_from_stock(gtk.STOCK_EXECUTE, gtk.ICON_SIZE_BUTTON))
    self.button_stop = gtk.Button(stock=gtk.STOCK_STOP)
    self.button_stop.set_sensitive(False)
    self.pgbar = gtk.ProgressBar()
    self.statusbar = gtk.Statusbar()
    self.cxtid_linecnt = self.statusbar.get_context_id('linecount')
    self.cxtid_status = self.statusbar.get_context_id('status')
    self.msgid_linecnt = None
    self.msgid_status = None
    # フレーム(説明のために使用・中身はスクロールウィンドウ)
    self.frame_input = gtk.Frame('Input')
    self.frame_input.add(self.sw_input)
    self.frame_found = gtk.Frame('OK')
    self.frame_found.add(self.sw_found)
    self.frame_notfound = gtk.Frame('NG')
    self.frame_notfound.add(self.sw_notfound)
    self.frame_notfound.set_size_request(-1, 100)
    self.frame_skipped = gtk.Frame('Skipped')
    self.frame_skipped.add(self.sw_skipped)
    self.frame_skipped.set_size_request(-1, 60)
    # レイアウト用コンテナ
    self.vbox_output = gtk.VBox()  # 中央右側の縦分割
    self.vbox_output.pack_start(self.frame_found)
    self.vbox_output.pack_start(self.frame_notfound)
    self.vbox_output.pack_start(self.frame_skipped)
    self.hbox_btn = gtk.HBox()  # 下の横分割
    self.hbox_btn.pack_start(self.button_start, expand=False, fill=False)
    self.hbox_btn.pack_start(self.button_stop, expand=False, fill=False)
    self.hbox_btn.pack_start(self.pgbar)
    self.hbox_textview = gtk.HBox()  # 中央の横分割
    self.hbox_textview.pack_start(self.frame_input)
    self.hbox_textview.pack_start(self.vbox_output)
    self.vbox = gtk.VBox()  # 全体の縦分割
    self.vbox.pack_start(self.menubar, expand=False, fill=False)
    self.vbox.pack_start(self.hbox_textview)
    self.vbox.pack_start(self.hbox_btn, expand=False, fill=False)
    self.vbox.pack_start(self.statusbar, expand=False, fill=False)
    # シグナル
    self.connect('delete_event', gtk.main_quit)
    self.item_quit.connect('activate', gtk.main_quit)
    self.button_start.connect('clicked', self.on_button_start_clicked)
    self.button_stop.connect('clicked', self.on_button_stop_clicked)
    # ウィンドウ
    self.add(self.vbox)
    self.set_size_request(350, 400)
    self.resize(400, 450)
  def to_update_progressbar(self):
    """
    処理中にのみ定期的に実行される関数
    プログレスバーを更新
    """
    fraction = float(self.domains_processed) / float(self.domains)
    self.pgbar.set_text('%d / %d' % (self.domains_processed, self.domains))
    self.pgbar.set_fraction(fraction)
    return True
  def resolve(self):
    """
    指定ドメインの名前解決を試みて結果をテキストバッファに出力
    処理の前後にはGUI部品の状態などを設定
    """
    # ステータスバー
    if self.msgid_status:
      self.statusbar.remove(self.cxtid_status, self.msgid_status)
    self.msgid_status = self.statusbar.push(self.cxtid_status, 'Processing...')
    # 行数カウントによるテキスト更新を一時的にブロック
    self.textbuf_input.handler_block(self.id_textbuf_input)
    # 出力先のテキストバッファを空にする
    for widget in (self.textbuf_found, self.textbuf_notfound, self.textbuf_notfound, self.textbuf_skipped):
      widget.set_text('')
    # 停止ボタンを押せるようにする
    self.button_stop.set_sensitive(True)
    # テキストビューの編集を禁止し、開始ボタンも押せないようにする
    for widget in (self.button_start, self.textview_input, self.textview_found, self.textview_notfound, self.textview_skipped):
      widget.set_sensitive(False)
    # プログレスバー更新のタイムアウト関数を設定
    tag = glib_timeout_add(150, self.to_update_progressbar)
    # テキストビューの各行のドメイン名について名前解決を試みて
    # 処理した項目は強調し(スタイルを付け)ていく
    iter_start = self.textbuf_input.get_start_iter()
    self.domains = self.textbuf_input.get_line_count()
    self.domains_processed = 0

    # 項目別にループで処理を行う
    while True:
      # メインループを回して表示反映
      while gtk.events_pending():
        gtk.main_iteration()
      iter_end = iter_start.copy()    # 位置情報をコピーして
      iter_end.forward_to_line_end()  # 行末(終点)に持っていく
      domain = self.textbuf_input.get_text(iter_start, iter_end)
      self.textbuf_input.place_cursor(iter_end)
      self.textbuf_input.apply_tag(self.txttag_active, iter_start, iter_end)
      self.textview_input.scroll_to_iter(iter_end, 0)
      if '/' in domain:  # ドメインの後ろにディレクトリを含むものは飛ばす
        iter_end_skipped = self.textbuf_skipped.get_end_iter()
        self.textbuf_skipped.place_cursor(self.textbuf_skipped.get_end_iter())
        self.textbuf_skipped.insert_at_cursor(domain + '\n')
        self.textview_skipped.scroll_to_mark(self.textbuf_skipped.get_insert(), 0)
      else:
        # 名前解決を試行し、結果に応じたテキストバッファに追加
        try:
          socket.gethostbyname(domain)
        except socket.gaierror:  # 失敗
          iter_end_notfound = self.textbuf_notfound.get_end_iter()
          self.textbuf_notfound.place_cursor(self.textbuf_notfound.get_end_iter())
          self.textbuf_notfound.insert_at_cursor(domain + '\n')
          self.textview_notfound.scroll_to_mark(self.textbuf_notfound.get_insert(), 0)
        else:                    # 成功
          self.textbuf_found.place_cursor(self.textbuf_found.get_end_iter())
          self.textbuf_found.insert_at_cursor(domain + '\n')
          self.textview_found.scroll_to_mark(self.textbuf_found.get_insert(), 0)
      self.domains_processed += 1
      # 次の行の先頭へ移動し、途中に終わりがあれば抜ける
      forward_line = iter_start.forward_line()
      if not forward_line:
        break
      # 次の名前解決処理までに少し間隔を空ける
      for _ in range(20):  # 0.1秒を20回、合計2秒ずつとする
        # メインループを回す
        while gtk.events_pending():
          gtk.main_iteration()
        # 停止ボタンが押された場合はsleepを中断する
        if self.stop_requested:
          glib_source_remove(tag)  # プログレスバーの更新を終了
          self.statusbar.remove(self.cxtid_status, self.msgid_status)
          self.msgid_status = self.statusbar.push(self.cxtid_status, 'Stopped')
          self.button_stop.set_sensitive(False)
          for widget in (self.button_start, self.textview_input, self.textview_found, self.textview_notfound, self.textview_skipped):
            widget.set_sensitive(True)
          self.textbuf_input.handler_unblock(self.id_textbuf_input)
          self.pgbar.set_fraction(0.0)
          self.pgbar.set_text('')
          self.textbuf_input.remove_tag(self.txttag_active, self.textbuf_input.get_start_iter(), self.textbuf_input.get_end_iter())
          return
        time.sleep(0.1)

    # 各種後始末
    glib_source_remove(tag)
    self.statusbar.remove(self.cxtid_status, self.msgid_status)
    self.msgid_status = self.statusbar.push(self.cxtid_status, 'Done')
    self.pgbar.set_text('Total:%d OK:%d NG:%d Skipped:%d' % (self.domains, self.textbuf_found.get_line_count() - 1, self.textbuf_notfound.get_line_count() - 1, self.textbuf_skipped.get_line_count() - 1))
    self.pgbar.set_fraction(1.0)
    self.button_stop.set_sensitive(False)
    for widget in (self.button_start, self.textview_input, self.textview_found, self.textview_notfound, self.textview_skipped):
      widget.set_sensitive(True)
    self.textbuf_input.handler_unblock(self.id_textbuf_input)
    self.textbuf_input.remove_tag(self.txttag_active, self.textbuf_input.get_start_iter(), self.textbuf_input.get_end_iter())
  def on_button_start_clicked(self, widget):
    """
    開始ボタンを押したときの処理
    """
    if self.textbuf_input.get_char_count() > 0:  # 完全に空のときは処理しない
      # stop_requestedは停止ボタンが押されたかどうかを示す真偽値で
      # 処理を行う関数側でこの値を監視しながらループする
      self.stop_requested = False
      self.resolve()
  def on_button_stop_clicked(self, widget):
    """
    停止ボタンを押したときの処理
    """
    self.stop_requested = True
  def on_textbuf_changed(self, widget):
    """
    テキストバッファが変更されたときの処理
    """
    # ステータスバーにドメイン数(テキストバッファの行数)を表示
    if self.msgid_linecnt:
      # 前回の文字列があれば消す
      self.statusbar.remove(self.cxtid_linecnt, self.msgid_linecnt)
    self.msgid_linecnt = self.statusbar.push(self.cxtid_linecnt, '%d Domain(s)' % self.textbuf_input.get_line_count())

class Filter2:
  """
  フィルタをフィルタするツール
  """
  def main(self):
    """
    アプリケーションのメイン処理
    """
    win = MainWindow()
    win.show_all()
    gtk.main()


if __name__ == '__main__':
  app = Filter2()
  app.main()

なお、「PyGTKでダイアログを出しつつ親ウィンドウも操作できるようにする」でも触れているように、gtk.main_iteration()の戻り値はメインループがgtk.main_quit()で終了するまではFalseで、その後はTrueとなる。

関連記事:

参考URL:

*1:落ちたり落ちなかったりと結構厄介で、いつ落ちるかも分からない

*2:ただし、(ディスク読み書きなどがなく)CPUにとって重い処理をマルチコアCPU環境でマルチスレッドにして実行するようにすると速くなることはあるようだ

*3:gobject.idle_add()でPyGObjectのバージョン2.16系からモジュールが移動されている