Conserver la réactivité de l'IHM

| Witold Wysota | Qt Quarterly |

Traducteur

Denys Bulant

Sur QtCentre, des personnes viennent à nous avec le problème récurrent de l'IHM gelant pendant de longues opérations. Le problème n'est pas difficile à résoudre, mais vous pouvez le faire de façons différentes, j'aimerais donc présenter un panel des options possibles à utiliser selon la situation à laquelle vous faites face.

Commentez

- Titre : Conserver la réactivité de l'IHM

- Auteurs : Witold Wysota, Qt Quarterly

- Traducteur : Denys Bulant

- Parution : 16 mai 2009

- Mise à jour : 27 décembre 2019

- Licence : Copyright ® 2009 Witold Wysota. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. L'article original

- II. Réaliser de longues opérations

- III. Traitement manuel des événements

- IV. Utiliser un thread de travail

- V. Patienter dans une boucle d'événement locale

- VI. Résoudre un problème étape par étape

- VII. Programmation parallèle

- VIII. Conclusion

- IX. Divers

I - L'article original

Qt Quarterly est une revue trimestrielle électronique proposée par Nokia à destination des développeurs et utilisateurs de Qt. Vous pouvez trouver les versions originales.

Nokia, Qt, Qt Quarterly et leurs logos sont des marques déposées de Nokia Corporation en Finlande et/ou dans les autres pays. Les autres marques déposées sont détenues par leurs propriétaires respectifs.

Cet article est la traduction de l'article Keeping the GUI Responsive de Witold Wysota paru dans la Qt Quarterly Issue 27.

Cet article est une traduction d'un des tutoriels écrits par Nokia Corporation and/or its subsidiary(-ies) inclus dans la documentation de Qt, en anglais. Les éventuels problèmes résultant d'une mauvaise traduction ne sont pas imputables à Nokia.

II - Réaliser de longues opérations

La première chose à faire est de spécifier le domaine du problème et de mettre en valeur les différentes façons de le résoudre. Le problème susmentionné peut prendre l'une des deux formes suivantes. Le programme peut exécuter une tâche intensive en calcul qui est décrite comme une série d'opérations réalisées séquentiellement de façon à obtenir le résultat final. Un exemple d'une telle tâche serait le calcul d'une transformée de Fourier rapide.

L'autre variation est lorsque le programme doit réaliser une certaine action (par exemple, un téléchargement réseau) et attendre sa complétion avant de passer à l'étape suivante de l'algorithme. Cette variation du problème est, en elle-même, facile à éviter en utilisant Qt, car la plupart des tâches asynchrones réalisées par le framework émettent un signal lorsqu'elles ont fini leur tâche. Vous pouvez alors y connecter un slot qui poursuivra l'algorithme.

Pendant les calculs (sans tenir compte de l'usage des signaux et slots), tout traitement d'événement est arrêté. Il en résulte une IHM qui n'est pas rafraîchie, les entrées utilisateur ne sont pas traitées, l'activité réseau s'arrête et les timers ne se déclenchent plus - l'application semble être gelée et, de fait, sa partie non liée à la tâche consommatrice l'est. À quel point est longue une « opération longue » ? Tout ce qui va distraire l'utilisateur final d'interagir avec l'application est long. Une seconde est longue, tout ce qui est plus long que deux secondes l'est définitivement trop.

Notre but, dans cet article, est de garder la fonctionnalité tout en évitant à l'utilisateur d'être irrité par une IHM gelée (ainsi que le réseau et les timers). Pour ce faire, regardons les différentes classes de solutions et les domaines problématiques.

Nous avons deux façons d'atteindre notre but (réaliser des calculs) - soit en les réalisant dans le thread principal (approche monothreadée) ou dans des threads séparés (approche multithreadée). Cette dernière est largement connue et utilisée dans le monde Java, mais est parfois abusée dans le cas où un thread pourrait parfaitement suffire. Contrairement à l'opinion populaire, les threads peuvent souvent ralentir votre application en lieu et place de l'accélérer. Donc à moins d'être certain qu'une approche multithreadée sera bénéfique à votre programme (que ce soit en termes de vitesse ou de simplicité), essayez d'éviter de multiplier les threads simplement parce que c'est possible.

Le domaine du problème peut être traité selon deux approches. Soit on peut diviser le problème en plus petites parts tels des étapes, itérations ou sous-problèmes (habituellement, ce ne devrait pas être monolithique), soit on ne peut pas. Si la tâche peut être divisée en éléments, chacun d'entre eux peut, ou non, dépendre des autres. S'ils sont indépendants, nous pouvons les traiter à tout moment et dans un ordre arbitraire. Dans le cas contraire, nous devons synchroniser notre tâche. Dans le pire des cas, nous ne pouvons traiter qu'un élément à la fois, et ne pouvons démarrer le prochain tant que le précédent n'est pas terminé. Prenant tout ceci en considération, nous pouvons choisir à partir de différentes solutions.

III - Traitement manuel des événements

La solution la plus basique est d'explicitement demander à Qt de traiter les événements en attente à un moment du calcul. Pour ce faire, vous devez appeler QCoreApplication::processEvents() de façon régulière *. L'exemple suivant montre comment procéder :

for (int i = 3; i <= sqrt(x) && isPrime; i += 2)
{
label->setText(tr("Checking %1...").arg(i));
if (x % i == 0)
isPrime = false;
QCoreApplication::processEvents();
if (!pushButton->isChecked())
{
label->setText(tr("Aborted"));
return;
}
}

Cette approche a de sérieux inconvénients. Par exemple, imaginez que vous vouliez réaliser deux de ces boucles en parallèle - appeler l'une d'elles arrêterait l'autre jusqu'à ce que la première soit finie (donc vous ne pouvez distribuer la puissance de calcul sur différentes tâches). Cela entraîne aussi un certain délai de réaction aux événements au niveau de l'application. De plus le code est difficile à lire et analyser, donc cette solution correspond seulement aux problèmes simples et courts qui doivent être traités dans un seul thread, tels que les écrans de démarrage et la surveillance d'opérations courtes.

En réalité, vous devriez appeler QCoreApplication::sendPostedEvents. Référez-vous à la documentation de processEvents().

IV - Utiliser un thread de travail

Une solution différente consiste à éviter de bloquer la boucle d'événements principale en réalisant les opérations longues dans un thread séparé. C'est tout particulièrement utile si la tâche est réalisée par une bibliothèque tierce de façon bloquante. Dans une telle situation, il peut ne pas être possible de l'interrompre pour laisser l'IHM traiter les événements en attente.

Une façon de réaliser une opération dans un thread séparé qui vous laisse presque maître sur le traitement est d'utiliser QThread. Vous pouvez soit en dériver et réimplémenter sa méthode run(), ou appeler QThread::exec() pour démarrer la boucle d'événements du thread, voire les deux : sous-classer et, quelque part dans la méthode run(), appeler exec(). Vous pouvez alors utiliser signaux et slots pour communiquer avec le thread principal - rappelez-vous simplement que vous devez être certain que QueuedConnection sera utilisé ou bien les threads pourraient perdre en stabilité et provoquer un crash de votre application.

Il y a plusieurs exemples d'utilisation des threads dans la documentation de référence Qt ainsi que dans les ressources en ligne, donc nous ne ferons pas notre propre implémentation, mais nous nous concentrerons plutôt sur d'autres aspects intéressants.

V - Patienter dans une boucle d'événement locale

La prochaine solution que j'aimerais décrire consiste à patienter jusqu'à ce qu'une tâche asynchrone soit finie. Ici, je vais vous montrer comment bloquer le flux jusqu'à ce qu'une opération réseau soit terminée sans bloquer le traitement des événements. Ce que nous pourrions faire est similaire à ceci :

task.start();
while (!task.isFinished())
QCoreApplication::processEvents();

Ceci est nommé attente active ou scrutation — constamment vérifier une condition jusqu'à ce qu'elle soit validée. Dans la plupart des cas, c'est une mauvaise idée qui tend à occuper tout votre CPU et possède tous les désavantages du traitement manuel des événements.

Fort heureusement, Qt a une classe pour nous aider dans cette tâche : QEventLoop est la même classe que l'application et les boîtes de dialogues modales utilisent dans leur fonction exec(). Chaque instance de cette classe est connectée au mécanisme principal de répartition des événements et, lorsque sa fonction exec() est appelée, commence à traiter des événements jusqu'à ce que vous lui disiez d'arrêter en appelant quit().

Nous pouvons utiliser ce mécanisme pour transformer des opérations asynchrones en opérations synchrones utilisant les signaux et slots - nous pouvons démarrer une boucle d'événements locale et lui dire de quitter lorsqu'elle reçoit un signal particulier d'un objet donné :

QNetworkAccessManager manager;
QEventLoop q;
QTimer tT;

tT.setSingleShot(true);
connect(&tT, SIGNAL(timeout()), &q, SLOT(quit()));
connect(&manager, SIGNAL(finished(QNetworkReply*)),
&q, SLOT(quit()));
QNetworkReply *reply = manager.get(QNetworkRequest(
QUrl("http://www.qtcentre.org")));

tT.start(5000); // 5s timeout
q.exec();

if(tT.isActive())
{
// téléchargemement fini
tT.stop();
}
else
{
// timeout
}

Nous utilisons l'une des nouveautés de Qt — un gestionnaire d'accès réseau — pour rapporter une URL distante. Puisqu'elle fonctionne en mode asynchrone, nous créons une boucle d'événements locale pour attendre un signal finished() du téléchargeur. De plus, nous instancions un timer qui va mettre fin à la boucle d'événements après cinq secondes si quelque chose ne va pas. Après avoir connecté les signaux appropriés, soumis la requête et lancé le timer, nous entrons dans la boucle d'événements nouvellement créée. L'appel à exec() ne va retourner que lorsque le téléchargement est complet ou que les cinq secondes sont écoulées (peu importe l'ordre). Nous vérifions dans quel cas nous sommes en vérifiant si le timer est toujours actif. Puis nous pouvons traiter le résultat ou prévenir l'utilisateur que le téléchargement a échoué.

Nous devrions noter deux choses ici. Premièrement, qu'une approche similaire est implémentée dans la classe QxtSignalWaiter qui fait partie du projet libqxt (http://www.libqxt.org). Autre chose, pour certaines opérations, Qt fournit une famille de commandes d'attente (par exemple, QIODevice::waitForBytesWritten()) qui vont faire plus ou moins la même chose que le snippet ci-dessus, mais sans lancer une boucle d'événements. Cependant, les solutions d'attente vont geler l'IHM, car elles ne font pas tourner leur propre boucle d'événements.

VI - Résoudre un problème étape par étape

Si vous pouvez diviser le problème en sous-problèmes, alors il y a une bonne option à prendre pour réaliser le calcul sans bloquer l'IHM. Vous pouvez réaliser la tâche par courtes étapes qui ne vont pas obstruer le traitement des événements pour de longues périodes de temps. Démarrez le traitement et, quand vous réalisez avoir passé un temps défini sur la tâche, sauvez son état et retournez à la boucle d'événements. Il doit y avoir un moyen de demander à Qt de continuer votre tâche une fois qu'il en a fini avec les événements.

Heureusement, un tel moyen existe ; il y en a même deux. L'un d'eux est d'utiliser un timer avec un intervalle réglé à zéro. Cette valeur spéciale va faire en sorte que Qt émette le signal timeout au nom du timer une fois que la boucle d'événements devient inactive. Si vous connectez ce signal à un slot, vous obtiendrez un mécanisme appelant des fonctions quand l'application n'est pas occupée à traiter quoi que soit d'autre (de façon similaire aux écrans de veille). Voici un exemple pour calculer les nombres premiers en tâche de fond :

class FindPrimes : public QObject
{
Q_OBJECT
public:
FindPrimes(QObject *parent = 0) : QObject(){}
public slots:
void start(qlonglong _max);
private slots:
void calculate();
signals:
void prime(qlonglong);
void finished();
private:
qlonglong cand, max, curr;
double sqrt;
void next(){ cand+=2; curr = 3; sqrt = ::sqrt(cand);}
};

void FindPrimes::start(qlonglong _max)
{
emit prime(1); emit prime(2); emit prime(3);
max = _max; cand = 3; curr = 3;
next();
QTimer::singleShot(0, this, SLOT(calculate()));
}

void FindPrimes::calculate()
{
QTime t;
t.start();
while (t.elapsed() < 150)
{
if (cand > max)
{
emit finished();        // fin
return;
}
if (curr > sqrt)
{
emit prime(cand);       // premier
next();
}
else if (cand % curr == 0)
next();                 // non premier
else
curr += 2;              // vérifier le premier diviseur
}
QTimer::singleShot(0, this, SLOT(calculate()));
}

La classe FindPrimes utilise deux fonctionnalités - elle maintient son état courant sur le calcul (les variables cand et curr) de façon à pouvoir continuer les calculs là où elle s'est arrêtée, et elle surveille (par l'utilisation de QTime::elapsed() ) depuis combien de temps elle réalise l'étape courante de la tâche. Si le temps passé est supérieur à un délai prédéfini, elle retourne à la boucle d'événements après avoir démarré un timer singleShot qui va appeler à nouveau la méthode (vous pourriez appeler cette approche « récursivité reportée »).

J'ai mentionné deux possibilités de réaliser une tâche par étapes. La seconde consiste à utiliser QMetaObject::invokeMethod() au lieu des timers. Cette méthode vous permet d'appeler n'importe quel slot de n'importe quel objet. Une chose doit être dite, pour que cette méthode fonctionne dans notre cas, nous devons être certain que l'appel est fait en utilisant le type de connexion Qt::QueuedConnection, de façon à ce que le slot soit appelé de façon asynchrone (par défaut, les appels de slots au sein d'un seul et même thread sont synchrones). Nous pourrions donc substituer l'appel au timer avec ceci :

QMetaObject::invokeMethod(this, "calculate",
Qt::QueuedConnection);

L'avantage de cette méthode sur les timers est que vous pouvez passer des arguments au slot (par exemple, lui passer l'état courant du calcul). Mis à part ce point, les deux méthodes sont équivalentes.

VII - Programmation parallèle

Finalement, il y a une situation où vous devez réaliser une opération similaire sur un ensemble de données - par exemple, la création d'aperçus des images d'un répertoire. Une implémentation triviale ressemblerait à ceci :

QList<QImage> images = loadImages(directory);
QList<QImage> thumbnails;
foreach (const QImage &image, images)
{
thumbnails << image.scaled(QSize(300,300),
Qt::KeepAspectRatio, Qt::SmoothTransformation);
QCoreApplication::sendPostedEvents();
}

Un inconvénient d'une telle approche est que la création d'un aperçu d'une seule image peut être réellement longue, et durant ce temps, l'IHM serait tout de même gelée. Une meilleure approche consisterait à réaliser l'opération dans un thread séparé :

QList<QImage> images = loadImages(directory);
ThumbThread *thread = new ThumbThread;
connect(thread, SIGNAL(finished(QList<QImage>)),
this, SLOT(showThumbnails(QList<QImage>)));
thread->start(images);

Cette solution répond bien à nos attentes, mais ne prend pas en compte le fait que les ordinateurs évoluent dans une direction différente d'il y a cinq ou dix ans - au lieu d'avoir des CPU de plus en plus rapides, ils sont équipés de plusieurs unités plus lentes (systèmes multicœurs et multiprocesseurs) qui, ensemble, fournissent plus de cycles de calculs avec une consommation d'énergie et une émission de chaleur réduites. Malheureusement, l'algorithme ci-dessus utilise seulement un thread et n'est donc exécuté que sur une seule unité de calcul, ce qui mène à une exécution plus lente sur les systèmes multicœurs que sur les monocœurs (car un cœur est plus lent dans un système multicœurs que monocœur).

Pour passer outre cette faiblesse, nous devons entrer dans le monde de la programmation parallèle - nous divisons le travail à faire en autant de threads que d'unités de calculs disponibles. À partir de Qt 4.4, il est fourni des extensions pour nous permettre de faire de la programmation parallèle : elles sont fournies par QThreadPool et Qt Concurrent.

La première solution possible est d'utiliser ce qui est appelé exécutables - de simples classes dont les instances peuvent être exécutées par un thread. Qt implémente les exécutables par sa classe QRunnable. Vous pouvez implémenter votre propre exécutable basé sur l'interface offerte par QRunnable et l'exécuter en utilisant une autre entité fournie par Qt. Je parle du thread pool - un objet qui peut générer un certain nombre de threads pour exécuter des tâches arbitraires. Si le nombre de tâches dépasse le nombre de threads disponibles, les tâches seront mises en file et exécutées dès qu'un thread devient disponible.

Revenons à notre exemple et implémentons un exécutable qui va créer un aperçu d'une image en utilisant un pool de thread.

class ThumbRunnable : public QRunnable
{
public:
ThumbRunnable(...)  : QRunnable(), ... {}
void run(){ m_result = m_image.scaled(...); }
const QImage &result() const{ return m_result; }
};

QList<ThumbRunnable *> runnables;
foreach(const QImage &image, images)
{
ThumbRunnable *r = new ThumbRunnable(image, ...);
r->setAutoDelete(false);
QThreadPool::globalInstance()->start(r);
runnables << r;
}

Au fond, tout ce qu'il y a à faire est implémenter la méthode run() de QRunnable. C'est réalisé de la même façon qu'en sous-classant QThread, la seule différence est que la tâche n'est pas liée à un thread qu'il crée et peut donc être invoquée par n'importe quel thread existant. Après avoir créé une instance de ThumbRunnable, nous nous assurons qu'elle ne sera pas détruite par le pool de thread une fois la tâche exécutée. Nous devons le faire, car nous voulons obtenir le résultat de ce même objet. Finalement, nous demandons au pool de thread de placer la tâche en file en utilisant le pool de thread global disponible pour chaque application et ajoutons l'exécutable à une liste pour nous en servir plus tard.

Nous devons alors contrôler périodiquement chaque exécutable afin de vérifier si le résultat est disponible, ce qui est ennuyeux, embarrassant. Heureusement, il y a une meilleure approche lorsque vous devez récupérer des résultats. Qt Concurrent introduit un certain nombre de paradigmes qui peuvent être invoqués afin de réaliser des opérations SIMD (Single Instruction Multiple Data, en français, une seule instruction, plusieurs données). Nous n'allons aborder ici que l'un d'entre eux, le plus simple, qui nous laisse traiter chaque élément d'un conteneur et obtenir les résultats dans un autre conteneur.

typedef QFutureWatcher<QImage> ImageWatcher;
QImage makeThumb(const QString &img)
{
return QImage(img).scaled(QSize(300,300), ...);
}

QStringList images = imageEntries(directory);
ImageWatcher *watcher = new ImageWatcher(this);
connect(watcher, SIGNAL(progressValueChanged(int)),
progressBar, SLOT(setValue(int)));
QFuture<QImage> result
= QtConcurrent::mapped(images, makeThumb);
watcher->setFuture(result);

Facile, n'est-ce pas ? Juste quelques lignes de code et le surveillant (watcher) nous informera de l'état du programme SIMD maintenu par l'objet QFuture. Il nous laissera même annuler, mettre en pause et reprendre l’exécution du programme. Ici, nous avons utilisé la variante la plus simple de l'appel - en utilisant une fonction simple. En situation réelle, vous utiliseriez quelque chose de plus sophistiqué qu'une simple fonction - Qt Concurrent vous laisse utiliser des fonctions, des méthodes de classe ainsi que des foncteurs. Les solutions tierces vous permettent d'étendre les possibilités en utilisant des arguments attachés à la fonction.

VIII - Conclusion

Je vous ai révélé un panel de solutions basé sur le type et la complexité du problème lié à la réalisation d'opérations coûteuses en temps dans les programmes utilisant Qt. Souvenez-vous que celles-ci ne sont que les bases - vous pouvez vous baser dessus, par exemple pour créer vos propres objets modaux en utilisant des boucles d'événements locales, des processeurs de données rapides en utilisant la programmation parallèle, et pour les plus complexes, diviser la tâche en plus petits éléments pourrait être la bonne direction.

Vous pouvez télécharger les exemples complets qui utilisent les techniques décrites dans cet article à partir du site web Qt Quarterly. Si vous voulez discuter de vos solutions, travailler avec d'autres personnes pour résoudre un problème qui vous gêne, ou plus simplement passer un peu de temps avec d'autres développeurs Qt, vous pouvez toujours nous rejoindre sur http://www.qtcentre.org.

Ne laissez plus jamais geler votre IHM !

IX - Divers

QtCentre étant un forum anglais, vous êtes bien évidemment invité à nous rejoindre sur le forum Qt de Développez.

Au nom de toute l'équipe Qt, j'aimerais adresser le plus grand remerciement à Nokia pour nous avoir autorisés à traduire cet article !

J'aimerais aussi adresser un immense merci à dourouc05 pour ses relectures et corrections, tout comme à Claude Leloup pour sa relecture orthographique !