2015年12月13日日曜日

QEventLoopでイベントループを自在に操る

この記事は Qt Advent Calender 2015 の14日の記事です。
QEventLoopクラスを使って非同期処理を同期的に扱う方法を紹介したいと思います。

QEventLoop はQtのイベントループを扱うクラスです。イベントループについては2日目の記事で分かりやすく解説されているので、詳しくない方はまずはこちらを参考にして下さい。

QEventLoop はQApplication::execやQDialog::execなどexecというメソッドを持つクラスの中で使われており、その中でQEventLoop::execが呼ばれて実際にイベントループが実行されます。QEventLoopはpublicなクラスなのでユーザのコードからも呼ぶことができ、これを使うことで非同期処理を同期な方法で呼ぶことができるようになります。実際に例を見てみましょう。

まずはQEventLoopを使わずにシグナルとスロットを用いて非同期処理をシーケンシャルに行うサンプルです。

#include <QApplication>
#include <QDebug>
#include <QObject>
#include <QThread>

class SleepThread : public QThread {
 protected:
  void run() {
    qDebug() << "doing heavy task";
    sleep(1);
  }
};

int main(int argv, char** args) {
  QApplication app(argv, args);

  SleepThread* thread1 = new SleepThread();
  QObject::connect(thread1, &QThread::finished, [&] {
    SleepThread* thread2 = new SleepThread();
    QObject::connect(thread2, &QThread::finished, [&] {
      SleepThread* thread3 = new SleepThread();
      QObject::connect(thread3, &QThread::finished, [&] {
        qDebug() << "finished all tasks!";
        app.exit();
      });
      thread3->start();
    });
    thread2->start();
  });
  thread1->start();
  app.exec();
}

以下の様な出力になります。’doing heavy task’は1秒毎に出力されます。

doing heavy task
doing heavy task
doing heavy task
finished all tasks!

SleepThreadはrunの中で何か重い処理をしていると仮定して、ここでは単に1秒スリープしています。SleepThreadのfinishedシグナルにC++11のラムダ関数をconnectしてスレッドの処理が終了したらラムダ関数が実行され、次のSleepThreadを生成・実行しています。3つ全ての非同期処理が終わったら”finished all tasks!”を表示して終了します。
ご覧のとおりネストが深くなって少々読みづらいですよね。これをQEventLoopを使って同期的に書き直してみます。

#include <QApplication>
#include <QDebug>
#include <QObject>
#include <QThread>
#include <QEventLoop>

class SleepThread : public QThread {
 protected:
  void run() {
    qDebug() << "doing heavy task";
    sleep(1);
  }
};

int main(int argv, char** args) {
  QApplication app(argv, args);

  SleepThread* thread1 = new SleepThread();
  QEventLoop loop1;
  QObject::connect(thread1, &QThread::finished, &loop1, &QEventLoop::quit);
  thread1->start();
  loop1.exec();

  SleepThread* thread2 = new SleepThread();
  QEventLoop loop2;
  QObject::connect(thread2, &QThread::finished, &loop2, &QEventLoop::quit);
  thread2->start();
  loop2.exec();

  SleepThread* thread3 = new SleepThread();
  QEventLoop loop3;
  QObject::connect(thread3, &QThread::finished, &loop3, &QEventLoop::quit);
  thread3->start();
  loop3.exec();

  qDebug() << "finished all tasks!";
}

出力はもちろんさっきと同じです。
QThead::finishedシグナルをQEventLoop::quitスロットにconnectして、スレッドが終了したらイベントループも終了するようにしています。その後QEventLoop::execを実行してイベントループに入ります。こうすることで非同期処理を行っているにも関わらず同期的に記述でき、プログラムもすっきりしました。さらにこの処理を関数として抽出すればもっと読みやすくできそうです。
QEventLoop::execを実行した後は見かけ上そこで処理が止まったようになりますが、実際はイベントループが回っているのでマウスやキーボードのイベントもその中で通常通り処理され、フリーズしたりはしません。

ネストしたイベントループ

このようにQEventLoopを使うことでいつでもイベントループを実行できることを確認しました。次はイベントループを実行した中でさらにイベントループを実行する例を見てみます。

main.cpp

#include <QApplication>
#include <QDebug>
#include <QObject>
#include "SleepThread.h"

int main(int argv, char** args) {
  QApplication app(argv, args);

  SleepThread* thread = new SleepThread();
  QObject::connect(thread, &QThread::finished, &app, &QApplication::quit);
  thread->moveToThread(thread);
  thread->start();
  QMetaObject::invokeMethod(thread, "sleep", Q_ARG(unsigned long, 3000));
  QMetaObject::invokeMethod(thread, "sleep", Q_ARG(unsigned long, 5000));
  app.exec();
}

main関数はSleepThread(先ほどのSleepThreadとは違います)を作成し、finishedシグナルにQApplication::quitをconnectしています。その後thread->moveToThread(thread) でthreadオブジェクトのthread affinityをSleepThreadに移します。
その後thread->start()でスレッドのイベントループを実行します。(今回QThread::runをオーバーライドしていないので、デフォルト動作のQThread::execが実行されます。)
QMetaObject::invokeMethodでSleepThreadのsleepメソッドを非同期で呼び出します。先ほどthread->moveToThread(thread) でthreadオブジェクトのthread affnityをSleepThreadに移したので、invokeMethodを呼び出した時にQt::QueuedConnectionが使われます。invokeMethodの詳細はQtのドキュメントを確認して下さい。
http://doc.qt.io/qt-5/qmetaobject.html#invokeMethod

invokeMethodでSleepThreadのsleepメソッドを2回非同期で呼び出した後app.exec()でQApplicationのイベントループに入ります。
次はSleepThreadの中身を見てみます。

SleepThread.h

#pragma once

#include <QThread>
#include <QEventLoop>
#include <QTimer>

class SleepThread : public QThread {
  Q_OBJECT
 public slots:
  void sleep(unsigned long msec) {
    QEventLoop loop;
    qDebug() << "start timer with" << msec << "[msec]";
    QTimer::singleShot(msec, &loop, [&loop, msec]{
      qDebug() << "finished waiting" << msec << "[msec]";
      loop.quit();
    });
    loop.exec();
    qDebug() << "event loop of" << msec << "[msec] finished";
    exit();
  }
};

sleepメソッドはQEventLoopでイベントループを実行しています。QTimer::singleShotを使ってmsecミリ秒後にイベントループを終了しています。今回なぜsleep(msec)としなかったかというと、sleep(msec)は現在のスレッドをブロックしてしまうため、SleepThreadで回っているイベントループを止めてしまうためです。
イベントループ終了後QThread::exitを呼び出してスレッドを終了します。

出力は以下のようになります。

start timer with 3000 [msec]
start timer with 5000 [msec]
finished waiting 3000 [msec]
finished waiting 5000 [msec]
event loop of 5000 [msec] finished
event loop of 3000 [msec] finished

イベントループはスレッドごとに存在していて、thread->start()でSleepThreadのイベントループが始まります。
次にQMetaObject::invokeMethod(thread, "sleep", Q_ARG(unsigned long, 3000)); を呼び出すとQt::QueuedConnectionの時に内部ではQMetaCallEventというイベントがSleepThreadのイベントループで発生し、sleep(3000) の形でメソッドが呼ばれます。sleepメソッドの中ではさらにQEventLoop::execでイベントループを実行しているため、イベントループがネストした形になります。
その後QMetaObject::invokeMethod(thread, "sleep", Q_ARG(unsigned long, 5000)); でsleep(5000)が実行され、さらに内部でイベントループが実行されます。

QThread::exec()で作られた親イベントループ
  sleep(3000)の中で作られた子イベントループ
    sleep(5000)の中で作られた孫イベントループ

このように3つイベントループがネストしていますが、実際にイベントを処理するのは常に一番深いイベントループで、他のイベントループは止まった状態になっています。この例ではsleep(3000)で作られた子イベントループの方がタイムアウト時間が短いので先にloop.quit(); が呼ばれますが、QEventLoop::quitを呼んでもこの子イベントループは止まっているので実際には即座には終了しません。その2秒後にsleep(5000)のQTimerのタイムアウトが発生しloop.quit(); が呼ばれ、sleep(5000)で作られた孫イベントループが終了します。
その後sleep(3000)の子イベントループが再開しますが、すでに孫イベントループの中でloop.quit()が呼ばれているので即座に終了します。
そしてようやく親イベントループが再開しますが、こちらもすでにsleepメソッドの中でQThread::exitが呼ばれているので、再開しても即座に終了します。

シグナル・スロットを用いた非同期処理とQEventLoopは相性がいいので、色々な場面で役立つこと間違いなしです!
明日はasobotさんのQtCreatorとqmakeについてです。

参考

0 件のコメント:

コメントを投稿