Filetage correct dans Qt

Qt est un framework extrêmement puissant et pratique pour C ++. Mais cette commodité a un inconvénient: beaucoup de choses dans Qt se produisent cachées à l'utilisateur. Dans la plupart des cas, la fonctionnalité correspondante de Qt fonctionne «par magie» et apprend à l'utilisateur à simplement prendre cette magie pour acquise. Cependant, lorsque la magie se brise néanmoins, il est extrêmement difficile de reconnaître et de résoudre un problème qui apparaît soudainement à un niveau apparemment plat.

Cet article est une tentative de systématiser comment Qt "sous le capot" implémente le travail avec les flux et sur un certain nombre de pièges non évidents associés aux limites de ce modèle.

Les bases
Affinité des threads, initialisation et leurs limites
Fil principal, QCoreApplication et GUI
Fil de rendu
Conclusion

Les bases


Commençons par les bases. Dans Qt, tous les objets capables de gérer des signaux et des emplacements sont des descendants de la classe QObject. De par leur conception, ces objets ne sont pas copiables et représentent logiquement certaines entités individuelles qui «parlent» entre elles - réagissent à certains événements et peuvent elles-mêmes générer des événements. En d'autres termes, QObject dans Qt implémente le modèle Actors . S'il est correctement implémenté, tout programme Qt n'est rien de plus qu'un réseau de QObjects interagissant les uns avec les autres dans lequel toute la logique du programme «vit».

En plus d'un ensemble de QObjects, un programme Qt peut inclure des objets de données. Ces objets ne peuvent pas générer et recevoir de signaux, mais peuvent être copiés. Par exemple, vous pouvez comparer QStringList et QStringListModel entre eux. L'un d'eux est QObject et n'est pas copiable, mais peut interagir directement avec les objets d'interface utilisateur, l'autre est un conteneur de données copiable normal. À leur tour, les objets contenant des données sont divisés en «méta-types Qt» et tous les autres. Par exemple, QStringList est un type méta Qt, mais std :: list <std :: string> (sans gestes supplémentaires) ne l'est pas. Le premier peut être utilisé dans n'importe quel contexte Qt-shnom (transmis via des signaux, se trouvant dans QVariant, etc.), mais nécessite une procédure d'enregistrement spéciale et la classe doit avoir un destructeur public, un constructeur de copie et un constructeur par défaut. Les seconds sont des types C ++ arbitraires.

Passez en toute transparence aux threads réels


Nous avons donc des «données» conditionnelles et il existe un «code» conditionnel qui fonctionne avec elles. Mais qui exécutera réellement ce code? Dans le modèle Qt, la réponse à cette question est explicitement définie: chaque QObject est strictement attaché à un thread QThread qui, en fait, est engagé dans la maintenance des emplacements et d'autres événements de cet objet. Un thread peut servir plusieurs QObjects à la fois, ou pas du tout, mais QObject a toujours un thread parent et il en est toujours exactement un. En fait, nous pouvons supposer que chaque QThread "possède" un ensemble de QObject. Dans la terminologie Qt, cela s'appelle l'affinité de thread. Essayons de visualiser pour plus de clarté:



À l'intérieur de chaque QThread se trouve une file d'attente de messages adressés aux objets que ce QThread «possède». Le modèle Qt suppose que si nous voulons qu'un QObject entreprenne une action, alors nous «enverrons» un message QEvent à ce QObject:

QCoreApplication::postEvent(QObject *receiver, QEvent *event, int priority); 

Dans cet appel thread-safe, Qt trouve le QThread auquel appartient l'objet récepteur, écrit le QEvent dans la file d'attente de messages de ce thread et réveille ce thread si nécessaire. Il est prévu que le code exécuté dans ce QThread à un moment donné lira le message de la file d'attente et effectuera l'action correspondante. Pour que cela se produise vraiment, le code de QThread doit entrer dans la boucle d'événement QEventLoop, créant l'objet approprié et l'appelant soit la méthode exec () soit la méthode processEvents (). La première option entre dans une boucle de traitement de message sans fin (avant que QEventLoop ne reçoive l'événement quit ()), la seconde se limite au traitement des messages qui se sont accumulés auparavant dans la file d'attente.



Il est facile de voir que les événements de tous les objets appartenant à un même thread sont traités séquentiellement. Si le traitement d'un événement par un thread prend beaucoup de temps, tous les autres objets seront «gelés» - leurs événements s'accumuleront dans la file d'attente du flux, mais ne seront pas traités. Pour éviter que cela ne se produise, Qt offre la possibilité d'un multitâche coopératif - les gestionnaires d'événements n'importe où peuvent «interrompre temporairement» en créant un nouveau QEventLoop et en lui passant le contrôle. Étant donné que le gestionnaire d'événements a également été précédemment appelé à partir de QEventLoop dans le flux, avec cette approche, une chaîne de boucles d'événements «imbriquées» les unes dans les autres est formée.

Quelques mots sur Event Dispatcher
À strictement parler, QEventLoop n'est rien de plus qu'un wrapper convivial sur une primitive dépendante du système de niveau inférieur appelée Event Dispatcher et implémente l'interface QAbstractEventDispatcher. C'est lui qui effectue la collecte et le traitement réels des événements. Un thread ne peut avoir qu'un seul QAbstractEventDispatcher et il n'est installé qu'une seule fois. Entre autres choses, à partir de Qt5, cela vous permet de remplacer facilement le répartiteur par un plus approprié si nécessaire en ajoutant seulement 1 ligne à l'initialisation du flux et sans toucher les endroits potentiellement nombreux où QEventLoop est utilisé.

Que comprend le concept d '«événement» traité dans un tel cycle? Bien connu de tous les employés de Qt, «signaux» n'est qu'un exemple particulier, QEvent :: MetaCall. Un tel QEvent stocke un pointeur sur les informations nécessaires pour identifier la fonction (slot) à appeler et ses arguments. Cependant, en plus des signaux dans Qt, il existe environ une centaine (!) D'autres événements, dont une douzaine sont réservés aux événements spéciaux Qt (ChildAdded, DeferredDelete, ParentChange) et le reste correspond à divers messages du système d'exploitation.

Pourquoi y en a-t-il tant et pourquoi il était impossible de se passer de signaux?
Le lecteur peut se demander: pourquoi y a-t-il tant d'événements et pourquoi il était impossible de s'en sortir avec un seul mécanisme de signal pratique et universel? Le fait est que différents signaux peuvent être traités très différemment. Par exemple, certains des signaux sont compressibles - si la file d'attente a déjà un message brut de ce type (par exemple QEvent :: Paint), les messages suivants le modifient simplement. D'autres signaux peuvent être filtrés. La présence d'un petit nombre de QEvents standard et facilement identifiables simplifie considérablement le traitement correspondant. De plus, le traitement QEvent dû à un dispositif sensiblement plus simple est généralement effectué un peu plus rapidement que le traitement d'un signal similaire.

L'un des pièges non évidents ici est que dans Qt, un flux, de manière générale, peut même ne pas avoir de Dispatcher, et donc pas un seul EventLoop. Les objets appartenant à ce flux ne répondront pas aux événements qui leur seront envoyés. Puisque QThread :: run () appelle par défaut QThread :: exec () à l'intérieur duquel le EventLoop standard vient d'être implémenté, ceux qui essaient souvent de déterminer leur propre version de run () héritée de QThread sont souvent confrontés à ce problème. Un cas d'utilisation similaire pour QThread est en principe tout à fait valide et est même recommandé dans la documentation, mais il va à l'encontre de l'idée générale d'organiser le code dans Qt décrite ci-dessus et ne fonctionne souvent pas comme les utilisateurs s'y attendent . Une erreur typique dans ce cas est une tentative d'arrêter un tel QThread personnalisé en appelant QThread :: exit () ou quit (). Ces deux fonctions envoient un message à QEventLoop, mais s'il n'y a tout simplement pas de QEventLoop dans le flux, il n'y a naturellement personne pour les traiter. En conséquence, les utilisateurs inexpérimentés essayant de "réparer une classe cassée" commencent à essayer d'utiliser un terminaison QThread :: "fonctionnel", ce qui est absolument impossible. Gardez à l'esprit - si vous redéfinissez run () et n'utilisez pas la boucle d'événement standard, vous devrez fournir un mécanisme pour quitter le flux vous-même - par exemple, en utilisant pour cela la fonction QThread :: requestInterruption () spécialement ajoutée. Il est plus correct, cependant, de ne pas hériter de QThread si vous n'implémentez pas vraiment un nouveau type spécial de threads et d'utiliser le QtConcurrent spécialement créé pour de tels scripts, ou de placer la logique dans un objet de travail spécial hérité de QObject, de placer ce dernier dans QThread standard et de gérer Travailleur utilisant des signaux.

Affinité des threads, initialisation et leurs limites


Ainsi, comme nous l'avons déjà compris, chaque objet dans Qt "appartient" à un flux. En même temps, une question logique se pose: à laquelle, en fait, exactement? Les conventions suivantes sont acceptées dans Qt:

1. Tous les «enfants» de tout «parent» vivent toujours dans le même flux que le parent

C'est peut-être la limitation la plus puissante du modèle de flux Qt, et les tentatives de le casser donnent souvent des résultats très étranges pour l'utilisateur. Par exemple, une tentative de création de setParent sur un objet vivant dans un autre thread dans Qt échoue simplement en silence (un avertissement est écrit sur la console). Apparemment, ce compromis a été atteint en raison du fait que la suppression sans fil des «enfants» dans le cas de la mort d'un parent vivant dans un autre fil est très non triviale et sujette à des bugs difficiles à attraper. Si vous souhaitez implémenter une hiérarchie d'objets en interaction vivant dans différents flux, vous devrez organiser la suppression vous-même.

2. Un objet dont le parent n'est pas spécifié lors de la création vit dans le flux qui l'a créé

Tout ici en même temps et simplement et en même temps n'est pas toujours évident. Par exemple, en vertu de cette règle, QThread (en tant qu'objet) vit dans un thread différent du thread qu'il contrôle lui-même (et en vertu de la règle 1, il ne peut posséder aucun des objets créés dans ce thread). Ou, si vous redéfinissez QThread :: run et créez des descendants QObject à l'intérieur, sans prendre de mesures spéciales (comme expliqué dans le chapitre précédent), les objets créés ne répondront pas aux signaux.

L'affinité de thread peut être modifiée si nécessaire en appelant QObject :: moveToThread. En vertu de la règle 1, seuls les «parents» de niveau supérieur (pour lesquels le parent == null) peuvent être déplacés, une tentative de déplacer n'importe quel «enfant» sera silencieusement ignorée. Lorsque le «parent» de niveau supérieur se déplace, tous ses «enfants» se déplacent également vers un nouveau flux. Curieusement, l'appel à moveToThread (nullptr) est également légal et est un moyen de créer un objet avec une affinité de thread «null»; ces objets ne peuvent recevoir aucun message.

Vous pouvez obtenir le thread d'exécution "actuel" via un appel à la fonction QThread :: currentThread (), le thread auquel l'objet est associé - via un appel à QObject :: thread ()

Une question intéressante sur l'attention
A noter que la mise en œuvre de la fonctionnelle de propriété des objets et de stockage des QEvents qui leur sont adressés, évidemment, nécessite le flux pour stocker quelque part les données correspondantes. Dans le cas de Qt, la classe de base QThread est généralement impliquée dans l'extraction et la gestion de ces données. Mais que se passe-t-il si vous créez un QObject dans un thread std :: ou appelez la fonction QThread :: currentThread () à partir de ce thread? Il s'avère que dans ce cas, Qt implicitement «en arrière-plan» créera un objet wrapper spécial non propriétaire QAdoptedThread. Dans le même temps, il incombe à l'utilisateur de s'assurer indépendamment que tous les objets d'un tel flux sont supprimés avant que le flux qui les a générés ne soit arrêté.

Fil principal, QCoreApplication et GUI


Parmi tous les threads, Qt distinguera certainement un «thread principal», qui dans le cas des applications d'interface utilisateur devient également un thread d'interface graphique. Dans ce thread réside l'objet QApplication (QCoreApplication / QGuiApplication) qui sert la boucle d'événement principale orientée pour fonctionner avec les messages du système d'exploitation. En vertu de la règle n ° 2 de la section précédente, dans la pratique, le thread "principal" sera celui qui a réellement créé l'objet QApplication, et comme dans de nombreux systèmes d'exploitation le "thread principal" a une signification particulière, la documentation recommande fortement de créer QApplication avec le tout premier objet dans son ensemble. Programmez Qt et faites-le immédiatement après le démarrage de l'application (== à l'intérieur du premier thread du processus). Pour obtenir un pointeur sur le thread principal de l'application, respectivement, vous pouvez utiliser une construction de la forme QCoreApplication :: instance () -> thread (). Cependant, purement techniquement, QApplication peut également être suspendu sur un flux non principal () , par exemple, si l'interface Qt est créée dans une sorte de plug-in et dans de nombreux cas, cela fonctionnera correctement.

En raison de la règle «les objets créés héritent du thread actuel», vous pouvez toujours travailler calmement sans dépasser les limites d'un thread. Tous les objets créés iront automatiquement au thread «principal» pour l'entretien, où il y aura toujours une boucle d'événement et (en raison de l'absence d'autres threads) il n'y aura jamais de problèmes de synchronisation. Même si vous travaillez avec un système plus complexe qui nécessite le multithreading, la plupart des objets tomberont très probablement dans le flux principal, à l'exception de quelques-uns qui seront explicitement placés ailleurs. C'est peut-être précisément cette circonstance qui donne naissance à l'apparente «magie» dans laquelle les objets semblent fonctionner indépendamment sans aucun effort (car le multitâche coopératif est mis en œuvre dans le flux) et en même temps ne nécessitent pas de synchronisation, de blocage ou similaires (parce que tout se passe dans un seul thread )

Outre le fait que le thread «principal» est le «premier» et contient la boucle de traitement d'événement QCoreApplication principale, une autre caractéristique de limitation de Qt est que tous les objets liés à l'interface graphique doivent «vivre» dans ce thread. Ceci est en partie une conséquence de Legacy: du fait que dans un certain nombre de systèmes d'exploitation, toutes les opérations avec l'interface graphique ne peuvent se produire que dans le thread principal, Qt subdivise tous les objets en «widgets» et «non-widgets». Un objet de type widget ne peut vivre que dans le thread principal, une tentative de "l'emporter" sur un tel objet dans n'importe quel autre s'embrasera automatiquement. En vertu de cela, il existe même une méthode spéciale QObject :: isWidgetType () qui reflète des différences internes assez profondes dans la mécanique de travail avec de tels objets. Mais il est intéressant de noter que dans le tout nouveau QtQuick, où ils ont essayé de s'éloigner de la béquille avec isWidgetType, le même problème est resté

Quelle est la question? Dans Qt5, les objets QML ne sont plus des widgets et peuvent être rendus dans un thread séparé. Mais cela a conduit à un autre problème - des difficultés de synchronisation. Le rendu des objets d'interface utilisateur est une «lecture» de leur état et doit être cohérent: si nous essayons de changer l'état d'un objet en même temps que son rendu, le résultat de la «race» qui en résulte peut ne pas nous plaire. De plus, OpenGL autour duquel le "nouveau" graphique Qt est construit est extrêmement "affûté" au fait que la formation des commandes de dessin est effectuée par un thread travaillant avec un état global - le "contexte graphique" qui ne peut changer que comme une série d'opérations séquentielles. Nous ne pouvons tout simplement pas dessiner simultanément deux objets graphiques différents sur l'écran - ils seront toujours dessinés séquentiellement l'un après l'autre. En conséquence, nous revenons à la même solution - le rendu de l'interface utilisateur est affecté à un thread. Un lecteur attentif, cependant, remarquera que ce thread ne doit pas être le thread principal - et dans Qt5, le framework essaiera vraiment d'utiliser un thread de rendu séparé pour cela.

Fil de rendu


Dans le cadre du nouveau modèle Qt5, tout le rendu des objets a lieu dans un thread spécialement alloué pour cela, le thread de rendu. Dans le même temps, pour que cela ait du sens et ne se limite pas à passer simplement d'un flux "principal" à un autre, les objets sont implicitement divisés en un "front-end" que le programmeur voit et généralement un "back-end" caché de lui qui effectue réellement le rendu réel. Le back end vit dans le thread de rendu, tandis que le front end, théoriquement, peut vivre dans n'importe quel autre thread. Il est supposé que le frontal exécute le travail utile (le cas échéant) sous la forme d'un traitement d'événements, tandis que la fonction principale n'est limitée que par le rendu. En théorie, il s'avère gagnant-gagnant: le dos «interroge» périodiquement l'état actuel des objets et les dessine sur l'écran, alors qu'il ne peut pas être «stoppé» par le fait que certains des objets «réfléchissaient» trop lors du traitement de l'événement en raison du fait que cela un traitement lent se produit dans un autre thread. À son tour, le flux de l'objet n'a pas besoin d'attendre les «réponses» du pilote graphique confirmant la fin du rendu, et différents objets peuvent fonctionner dans différents flux.

Mais comme je l'ai déjà mentionné dans le chapitre précédent, puisque nous avons un flux qui crée des données (un front) et un flux qui les lit (au dos), nous devons en quelque sorte les synchroniser. Cette synchronisation dans Qt se fait par des verrous. Le flux où vit le front est temporairement suspendu, suivi d'un appel de fonction spéciale (QQuickItem :: updatePaintNode (), QQuickFramebufferObject :: Renderer :: synchronize ()) dont la seule tâche est de copier l'objet pertinent pour visualiser l'état d'avant en arrière ". Dans ce cas, l'appel d'une telle fonction se produit à l'intérieur du thread de rendu , mais du fait que le thread où se trouve l'objet à ce moment est arrêté, l'utilisateur peut librement travailler avec les données de l'objet comme si cela s'était passé «comme d'habitude», à l'intérieur du flux auquel appartient l'objet.

Tout va bien, tout va bien? Malheureusement, non, et des moments assez évidents commencent ici. Si nous prenons un verrou individuellement pour chaque objet, ce sera plutôt lent car le thread de rendu sera obligé d'attendre que ces objets finissent de traiter leurs événements. Le flux de «blocage» où se trouve l'objet est «blocage» et rendu. De plus, une «désynchronisation» deviendra possible lorsque, lorsque deux objets sont modifiés simultanément, l'un sera dessiné dans le cadre N et l'autre sera dessiné uniquement dans le cadre N + 1. Il serait préférable de ne prendre le verrou qu'une seule fois et pour tous les objets à la fois et uniquement lorsque nous sommes sûrs que ce verrouillage réussira.

Qu'est-ce qui a été implémenté pour résoudre ce problème dans Qt? Premièrement, il a été décidé que tous les objets "graphiques" d'une fenêtre vivraient dans un même flux. Ainsi, pour dessiner une fenêtre et verrouiller tous les objets qu'elle contient, il suffit d'arrêter ce flux seul. Deuxièmement, le thread avec des objets d'interface utilisateur initie le verrou pour la mise à jour du back-end, envoyant un message au thread de rendu sur la nécessité de se synchroniser et de s'arrêter (QSGThreadedRenderLoop :: polishAndSync si quelqu'un est intéressé). Cela garantit que le thread de rendu n'attendra jamais un flux frontal. S'il se "bloque" soudainement, le thread de rendu continuera simplement à dessiner "l'ancien" état des objets sans recevoir de messages sur la nécessité de mettre à jour. Cela donne vraiment lieu à des bugs assez amusants de la forme «si pour une raison quelconque le rendu ne peut pas dessiner la fenêtre immédiatement, le thread principal se fige», mais en général c'est un compromis raisonnable. À partir de QtQuick 2.0, un certain nombre d'objets "animés" peuvent même être "remplis" dans le fil de rendu afin que l'animation puisse également continuer à fonctionner si le fil principal est "pensé".



Cependant, la conséquence pratique de cette solution est que tous les objets d'interface utilisateur doivent de toute façon vivre dans le même thread. Dans le cas d'anciens widgets, dans le thread "principal", dans le cas de nouveaux objets Qt Quick, dans le thread d'objet QQuickWindow qui les possède. La dernière règle est assez élégamment battue - pour dessiner un QQuickItem, il doit faire de setParent le QQuickWindow correspondant qui, comme déjà discuté, garantit que l'objet se déplace vers le flux correspondant ou que l'appel setParent échoue.

Et maintenant, hélas, une mouche dans la pommade: bien que différents QQuickWindow puissent théoriquement vivre dans différents flux, cela nécessite en pratique l'envoi précis de messages du système d'exploitation à eux et dans Qt aujourd'hui, il n'est pas implémenté. Dans Qt 5.13, par exemple, QCoreApplication essaie de communiquer avec QQuickWindow via sendEvent en exigeant que le récepteur et la partie émettrice soient dans le même thread (au lieu de postEvent qui permet aux threads d'être différents). Par conséquent, dans la pratique, QQuickWindow ne fonctionne correctement que dans un thread GUI et, par conséquent, tous les objets QtQuick vivent au même endroit. Par conséquent, malgré la présence du thread de rendu, presque tous les objets liés à l'interface graphique disponibles pour l'utilisateur vivent toujours dans le même thread GUI. Peut-être que cela va changer dans Qt 6.

En plus de ce qui précède, il convient également de se rappeler que, puisque Qt fonctionne sur de nombreuses plates-formes différentes (y compris celles qui ne prennent pas en charge le multithreading), le cadre fournit un nombre décent de solutions de secours et, dans certains cas, la fonctionnalité du fil de rendu est en fait exécutée par le même fil gui . Dans ce cas, l'interface utilisateur entière, y compris le rendu, vit dans un seul thread et le problème de synchronisation disparaît automatiquement. La situation est similaire avec l'ancienne interface utilisateur basée sur un widget de style Qt4. Si vous le souhaitez, vous pouvez faire fonctionner Qt dans ce mode "monothread" en définissant la variable d'environnement QSG_RENDER_LOOP sur l'option appropriée.

Conclusion


Qt est un framework énorme et complexe, et travailler avec des threads reflète une partie de cette complexité. Mais il a été conçu très soigneusement, logiquement et avec compétence, donc lorsque vous comprenez plusieurs idées clés avec des flux dans Qt, il est assez simple de travailler sans erreurs.

Permettez-moi de vous rappeler à nouveau les points principaux;

  • Chaque objet possède un thread qui le possède, exécutant des gestionnaires de tous les événements se produisant avec l'objet, y compris le traitement des signaux en file d'attente
  • Si le thread "propriétaire" de l'objet n'exécute pas Qt Event Loop, alors les objets lui appartenant ne recevront aucun message et le thread lui-même ne répondra pas aux tentatives de lui dire exit ()
  • Les parents et les descendants vivent toujours dans le même flux. Seul le parent de niveau supérieur peut être transféré d'un flux à l'autre. La violation de cette règle peut entraîner l'échec silencieux de l'opération setParent ou moveToThread
  • Un objet dont le parent n'est pas spécifié devient la propriété du thread que cet objet a créé.
  • Tous les objets GUI à l'exception du back-end de rendu doivent vivre dans le flux GUI
  • Le thread GUI est celui dans lequel l'objet QApplication a été créé

J'espère que cela vous aidera à utiliser Qt plus efficacement et à ne pas faire d'erreurs associées à son modèle multi-thread.

Source: https://habr.com/ru/post/fr467261/


All Articles