こんにちは。田原です。

今回は前回に引き続き、Signal/Slotのスレッド間通信機能について解説を進めます。SignalをSlotへconnectして使いますが、そのconnect方法とSignalを発行(emit)したスレッドによって通常のコールバック(直接呼出)かキュー経由の呼出(∋スレッド間通信)なのかが決まります。その決まり方がちょっと分かりにくいので今回はどのような時にキュー経由の呼出になるのかに焦点を当てて解説します。

1.原理

分かってしまえば原理は簡単ですので、ざっくり解説します。

1-1. イベント・ループ

Windowsでは、ウィンドウ(GUI)を制御するためにメッセージ・ループを回しています。リンク先のWikePediaによるとMacなどの他のGUIシステムも同様ですね。

そして、Qtも同じようにイベント・ループでGUIイベントを処理します。更にGUIと無関係な非同期処理イベントもイベント・ループを使います。
以下は第4回目のサンプルですが、このapp.exec()でイベント・ループを回っています。

#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQuickWindow>
 
int main(int argc, char *argv[])
{
    QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
 
    QGuiApplication app(argc, argv);
 
    QQmlApplicationEngine engine;
    const QUrl url("qrc:/main.qml");
    engine.load(url);
    QQuickWindow* aWindow=dynamic_cast<QQuickWindow*>(engine.rootObjects().first());
    aWindow->setTitle("Hello QML!");
 
    return app.exec();
}

イベント・ループの本質的な処理はごく簡単です。
「①キューにイベントが入っていなかったら入るのを待つ、②入っていたらイベントを1つ取り出し、③取り出したイベントに対応する処理関数を呼び出す」という単純なものです。
ここでのポイントはキューを使っていることです。キュー(待ち行列)で処理されているので強力なのです。

マルチ・スレッドでは異なるスレッドから複数の処理要求がほぼ同時に発生することがあります。それらの要求をキューに入れて順番に処理することで処理をシリアライズできるので複数の要求が同時に処理されることがありません。
保護するべき変数やリソースが多岐に渡る場合や処理の順序が問題になる場合は、Mutex等の保護では不十分ですが、そのような場合でも使えます。ただし、「キューを用意し、エンキュー(キューに並べる)/デキュー(キューから取り出す)処理の実装」に手間がかかりますね。それをQt側が用意してくれるので比較的容易に使えます。

非同期処理の2大デザイン・パターン:
非同期処理を行う場合のパターンとして大きく2種類あると感じます。
・1つはイベント・ループを回してイベントが発生する度に該当の関数を呼び出す方式です。GUIのイベント・ループ状態マシンが代表的な実装と思います。
・もう1つはスレッド(やスタック・フルなコ・ルーチン)で時間のかかる処理を行う方式です。

前者は比較的お手軽に実装できますが、複雑なシーケンス(例えば共通なサブシーケンスを他の複数のシーケンスから呼び出したいなど)を実装する時は構造が複雑になり、最後はとても手に負えなくなります。
後者はそのような時でも比較的スマートに記述できますが、複雑なシーケンスを中断したい時の処理が常に悩ましく、その設計に手間がかかります。(中断処理まで含めた比較的適用が容易な定形パターンは少なくともまだメジャーにはなっていないようです。恐らく既に開発はされているのではないかと予想しているのですが。)

1-2. スレッドとイベント・ループの関係

まず、Qtでマルチ・スレッド・プログラミングする時はできるだけQThreadを使うのが良さそうです。C++標準のstd::threadを使うことも考えられるのですが、QThreadはQtの根幹部分(Signal/Slot)と強く関連しているので、かなり苦労しそうな予感がします。

さて、QThreadはstart()関数を呼び出してスレッドの実行を始めると、QThread::run()関数が呼び出されます。run()関数をオーバーライドしなければこの中でQThread::exec()を呼び出しています。QThread::exec()は単純にイベント・ループを回っています。

Google翻訳
Qt公式の説明をGoogle Chromeの翻訳機能は不得手なようですが、Google翻訳の方はそれなりに訳してくれますので、QThread::exec()の公式の説明をGoogle翻訳で翻訳してみました。

イベントループに入り、exit()が呼び出されるまで待機し、exit()に渡された値を返します。 exit()がquit()を介して呼び出された場合、返される値は0です。

この関数は、run()内から呼び出されることを意図しています。 イベント処理を開始するには、この関数を呼び出す必要があります。

そのイベント・ループでチェックするキューはスレッド毎に1つです。つまり、1つのスレッドが複数のキューからデキューすることはないですし、1つのキューを複数のスレッドで共有することもありません。スレッドとキューは1対1対応していて、その対応関係は変更できないようです。

1-3. QObjectのインスタンスは1つのスレッドと関連付けられている

Qtの事実上のルート・クラスはQObjectですね。そのQObjectは特定の1つのスレッドと関連付けられています。(当然ですが、QObjectを直接/間接に継承したクラスのインスタンスも同様に特定の1つのスレッドと関連付けられています。)
従って、Qtのインスタンスのほとんどは特定の1つのスレッドと関連付けられています。

では、それらのQtインスタンスがどのスレッドと関連付けられているのか?が問題ですね。それはざっくり以下の通りです。

①デフォルトでは当該インスタンスをコンストラクトしたスレッドと関連付けられます。
②当該インスタンスのmoveToThread()関数で異なるスレッドへ関連付けを変えることができます。(注1)

メイン・スレッドで例えばQTimerを生成した場合、そのQTimerはメイン・スレッドと関連つけられます。
例えば下記のaTimerはメイン・スレッドと関連付けられています。

#include <QCoreApplication>
#include <QTimer>

#include "Common.h"

int main(int argc, char *argv[])
{
    QCoreApplication app(argc, argv);

    QTimer aTimer;
    aTimer.setInterval(1000);
    aTimer.setSingleShot(true);
    QObject::connect(&aTimer, &QTimer::timeout,
        []()
        {
            log("timeout!!");
            qApp->exit();
        });
    aTimer.start();

    return app.exec();
}

注1
ただし、親を持たないインスタンスのみmoveToThreadできますのでご注意下さい。親を持つインスタンスはmoveToThreadできません

Qtでは、”QTimer* timer = new QTimer(this);” のような方法でインスタンスをコンストラクトするソースをよく見かけます。このように書いた場合、thisがtimerの親となります。このようにすると親がデストラクトされる際にtimerインスタンスも一緒にデストラクトされます。親クラスのコンストラクトよりも後でコンストラクトしたいようなケースで使うと便利と思います。

1-4. そしてconnect関数でエンキューするかどうか指定する

前回解説したようにconnect関数でQt::QueuedConnectionかQt::BlockingQueuedConnectionを指定するとsignalを発行(emit)した時、そのイベントがエンキューされ、そのキューをイベント・ループでチェックしているスレッドにてデキューされ処理されます。

どのスレッドなのか?ですが、ほとんどのconnect関数は受信者=reciever(QObject*)を指定しますね。そのrecieverに指定したQObjectのインスタンスと関連付けられたスレッドのキューです。

例外は3つあります。(ほとんどのQObject::connectはstatic関数であることにご注意下さい。)

  1. メンバー関数のconnectは、このconnect関数が属するQObjectのインスタンスと関連付けられたスレッドとなります。
  2. Slot関数のみ指定し、recieverやcontextを指定しないconnectはQt::ConnectionType も指定しません。これはQt::DirectConnectionのみサポートしキュー経由の接続をサポートしていないからです。
  3. そして、上記のタイプ+contextを指定するconnectは、もう見当が付いた方もいらっしゃると思いますが、contextで指定したインスタンスと関連付けられたスレッドとなります。

1-5. 最後に問題のQt::AutoConnection

connect関数は最後のパラメータで Qt::ConnectionType を指定し、このデフォルト値は Qt::AutoConnection です。つまり、connect関数で最もよく使われる接続タイプは Qt::AutoConnection です。
これはSlotの2種類の呼び出し方、「直接呼び出し(単純なコールバック)」と「キュー経由呼び出し」を自動的に切替えます。

その切替え方は単純で、Signalを発行(emit)したスレッドと「上述の受信者(recieverやcontext)となるQobjectのインスタンスと関連付けられたスレッド」が「同じならQt::DirectConnection」、「異なるならQt::QueuedConnection」が使われます。

(なお、デッドロックするリスクの高いQt::BlockingQueuedConnectionが自動的に使われることはありません。Qt::BlockingQueuedConnectionを使う時は明示的に指定します。)

1-6. おまけ:超注意事項

私もハマってしまったのですが、要注意事項があります。

QThreadを派生したクラス(以下Inheritanceクラスと呼ぶ)を作ることもあると思います。(run関数をオーバーライドしたい時など)
その時、直感的にはInheritanceクラスは基底クラスQThreadと関連付けられそうに感じてしまいます。
しかし、ここも1-3.で解説したルールが適用されます。

Inheritanceクラスのコンストラクタが呼び出される直前は、当然まだ基底クラスのQThreadはコンストラクトさえされていません。まして走っていないです。従ってInheritanceクラスのインスタンスをコンストラクトする際に基底クラスのスレッドと関連付けられることはありえません。
もし、基底クラスのスレッドに関連付けたい時は、moveToThreadで関連付けを変更することになります。

2.サンプル・ソース

簡単ですが、1-6.の例と1-5.の例を1セットのサンプルにまとめてみました。

Inheritanceクラスが1-6.で記載した「Inheritanceクラス」です。コメントアウトしているmoveToThreadが無効のままならSlot関数はメイン・スレッドで呼ばれます。直感的にはこれだけで基底クラスのスレッドでよばれそうに感じてしまうので要注意です。moveToThreadを有効にすることでサブ・スレッド(=基底クラスのスレッド)で呼ばれるようになります。

またContainmentクラスにて1-5.のQt::AutoConnectionの例を示します。

今回のサンプルの主要部分のソースです。全体はGistにアップロードしていますので参考にして下さい。(Windowsでテストしています。)





実行結果
21:48:04.409 0x54c8 start Inheritance()
21:48:04.413 0x54c8 end   Inheritance()
21:48:04.414 0x54c8 start Containment()
21:48:04.414 0x54c8 end   Containment()
21:48:04.517 0x54c8 ---------------------- Inheritance
21:48:04.518 0x54c8 start testStart()
21:48:04.519 0x54c8 end   testStart()
21:48:04.521 0x54c8      queuedSlot()
21:48:04.616 0x54c8 ---------------------- Containment
21:48:04.620 0x54c8 start testStart()
21:48:04.621 0x54c8 end   testStart()
21:48:04.621 0x654c       autoConnectionSlot()
21:48:04.622 0x654c start testSlot()
21:48:04.623 0x654c       autoConnectionSlot()
21:48:04.624 0x654c end   testSlot()
21:48:04.716 0x54c8 ---------------------- finish
21:48:04.717 0x54c8 start ~Containment()
21:48:04.718 0x54c8 end   ~Containment()
21:48:04.719 0x54c8 start ~Inheritance()
21:48:04.720 0x54c8 end   ~Inheritance()

3.最後に

若干駆け足になったと思いますが、これでSignal/SlotとQThreadの関係の重要な部分は解説できたと思います。

私は複雑なシーケンスを記述する時はサブ・スレッドを使うことにしており、その時は最初に中断のためのロジック(イベント待ちロジック)を設計します。しかし、最初はSignal/SlotとQThreadが関係していることに全く気が付かず、その際の設計で試行錯誤がかなり発生してしまい、なかなか苦労しました。

そのような羽目に陥る人が少しでも減ればと思い、当エントリーを作成してみました。
うまく行くことを願いつつ、今回はこの辺で終わりたいと思います。お疲れさまでした。