Roscado adecuado en Qt

Qt es un marco extremadamente potente y conveniente para C ++. Pero esta conveniencia tiene un inconveniente: muchas cosas en Qt suceden ocultas para el usuario. En la mayoría de los casos, la funcionalidad correspondiente en Qt funciona "mágicamente" y le enseña al usuario a simplemente dar por sentado esta magia. Sin embargo, cuando la magia se rompe, es extremadamente difícil reconocer y resolver un problema que aparece repentinamente en un nivel aparentemente plano.

Este artículo es un intento de sistematizar la forma en que Qt "bajo el capó" implementa el trabajo con flujos y sobre una serie de dificultades no obvias asociadas con las limitaciones de este modelo.

Los fundamentos
Hilo de afinidad, inicialización y sus limitaciones.
Hilo principal, QCoreApplication y GUI
Hilo de renderizado
Conclusión

Los fundamentos


Comencemos con lo básico. En Qt, cualquier objeto capaz de manejar señales y ranuras son descendientes de la clase QObject. Estos objetos por diseño no se pueden copiar y, lógicamente, representan algunas entidades individuales que "hablan" entre sí, reaccionan a ciertos eventos y pueden generar eventos por sí mismos. En otras palabras, QObject en Qt implementa el patrón de actores . Si se implementa correctamente, cualquier programa Qt es esencialmente nada más que una red de QObjects que interactúan entre sí en la que toda la lógica del programa "vive".

Además de un conjunto de QObjects, un programa Qt puede incluir objetos de datos. Estos objetos no pueden generar y recibir señales, pero pueden copiarse. Por ejemplo, puede comparar QStringList y QStringListModel entre ellos. Uno de ellos es QObject y no es copiable, pero puede interactuar directamente con los objetos de la interfaz de usuario, el otro es un contenedor de datos copiable normal. A su vez, los objetos con datos se dividen en "Qt Meta-tipos" y todos los demás. Por ejemplo, QStringList es un metatipo Qt, pero std :: list <std :: string> (sin gestos adicionales) no lo es. El primero se puede usar en cualquier contexto Qt-shnom (transmitido a través de señales, en QVariant, etc.), pero requiere un procedimiento de registro especial y la clase debe tener un destructor público, un constructor de copias y un constructor predeterminado. Los segundos son tipos arbitrarios de C ++.

Pase sin problemas a los hilos reales


Entonces, tenemos "datos" condicionales y hay un "código" condicional que funciona con ellos. Pero, ¿quién ejecutará realmente este código? En el modelo Qt, la respuesta a esta pregunta se establece explícitamente: cada QObject está estrictamente vinculado a algún hilo QThread que, de hecho, se dedica al servicio de ranuras y otros eventos de este objeto. Un hilo puede servir muchos QObjects a la vez, o ninguno en absoluto, pero QObject siempre tiene un hilo padre y siempre es exactamente uno. De hecho, podemos suponer que cada QThread "posee" un conjunto de QObject. En la terminología Qt, esto se llama Thread Affinity. Tratemos de visualizar para mayor claridad:



Dentro de cada QThread hay una cola de mensajes dirigidos a objetos que este QThread "posee". En el modelo Qt, se supone que si queremos que un QObject realice alguna acción, "enviamos" un mensaje QEvent a este QObject:

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

En esta llamada segura para subprocesos, Qt encuentra el QThread al que pertenece el objeto receptor, escribe el QEvent en la cola de mensajes de este subproceso y lo activa si es necesario. Se espera que el código que se ejecuta en este QThread en algún momento posterior lea el mensaje de la cola y realice la acción correspondiente. Para que esto suceda realmente, el código en QThread debe ingresar el bucle de eventos QEventLoop, crear el objeto apropiado y llamarlo ya sea el método exec () o el método processEvents (). La primera opción ingresa un bucle de procesamiento de mensajes sin fin (antes de que QEventLoop reciba el evento quit ()), la segunda se limita al procesamiento de mensajes que se han acumulado previamente en la cola.



Es fácil ver que los eventos para todos los objetos que pertenecen a un hilo se procesan secuencialmente. Si el procesamiento de un evento por un subproceso lleva mucho tiempo, todos los demás objetos se "congelarán"; sus eventos se acumularán en la cola de la secuencia, pero no se procesarán. Para evitar que esto suceda, Qt ofrece la posibilidad de multitarea cooperativa: los controladores de eventos en cualquier lugar pueden "interrumpir temporalmente" creando un nuevo QEventLoop y pasándole el control. Dado que el controlador de eventos también se llamó previamente desde QEventLoop en la secuencia, con este enfoque, se forma una cadena de bucles de eventos "anidados" entre sí.

Algunas palabras sobre el despachador de eventos
Estrictamente hablando, QEventLoop no es más que un contenedor fácil de usar sobre una primitiva dependiente del sistema de nivel inferior llamada Event Dispatcher e implementa la interfaz QAbstractEventDispatcher. Es él quien realiza la recopilación y el procesamiento reales de los eventos. Un subproceso puede tener solo un QAbstractEventDispatcher y se instala solo una vez. Entre otras cosas, comenzando con Qt5, esto le permite reemplazar fácilmente el despachador por uno más adecuado si es necesario agregando solo 1 línea a la inicialización de la transmisión y sin tocar los lugares potencialmente numerosos donde se usa QEventLoop.

¿Qué se incluye en el concepto de "evento" procesado en dicho ciclo? Bien conocido por todos los empleados de Qt, "señales" es solo un ejemplo particular, QEvent :: MetaCall. Tal QEvent almacena un puntero a la información necesaria para identificar la función (ranura) que debe llamarse y sus argumentos. Sin embargo, además de las señales en Qt, hay alrededor de un centenar (!) Otros eventos, de los cuales una docena está reservada para eventos especiales de Qt (ChildAdded, DeferredDelete, ParentChange) y el resto corresponde a varios mensajes del sistema operativo.

¿Por qué hay tantos y por qué era imposible hacerlo sin solo señales?
El lector puede preguntar: ¿por qué hay tantos eventos y por qué fue imposible sobrevivir con un solo mecanismo de señal universal y conveniente? El hecho es que diferentes señales pueden procesarse de manera muy diferente. Por ejemplo, algunas de las señales son compresibles: si la cola ya tiene un mensaje sin procesar de este tipo (por ejemplo, QEvent :: Paint), los mensajes posteriores simplemente lo modifican. Se pueden filtrar otras señales. La presencia de un pequeño número de QEvents estándar y fácilmente identificables simplifica significativamente el procesamiento correspondiente. Además, el procesamiento de QEvent debido a un dispositivo notablemente más simple generalmente se lleva a cabo algo más rápido que el procesamiento de una señal similar.

Una de las trampas más obvias aquí es que en Qt, una secuencia, en términos generales, puede que ni siquiera tenga un Despachador y, por lo tanto, ni un solo EventLoop. Los objetos que pertenecen a esta secuencia no responderán a los eventos que se les envíen. Dado que QThread :: run () por defecto llama a QThread :: exec () dentro del cual se acaba de implementar el EventLoop estándar, aquellos que a menudo intentan determinar su propia versión de run () heredada de QThread a menudo enfrentan este problema. Un caso de uso similar para QThread es, en principio, bastante válido e incluso se recomienda en la documentación, pero va en contra de la idea general de organizar el código en Qt descrito anteriormente y, a menudo, no funciona como esperan los usuarios. Un error típico en este caso es un intento de detener un QThread personalizado llamando a QThread :: exit () o quit (). Ambas funciones envían un mensaje a QEventLoop, pero si simplemente no hay QEventLoop en la secuencia, entonces naturalmente no hay nadie para procesarlas. Como resultado, los usuarios inexpertos que intentan "arreglar una clase rota" comienzan a intentar usar un QThread :: terminate "funcional", lo cual es absolutamente imposible. Tenga en cuenta que si redefine run () y no utiliza el bucle de eventos estándar, deberá proporcionar un mecanismo para salir del hilo usted mismo, por ejemplo, usando la función QThread :: requestInterruption () especialmente agregada para esto. Sin embargo, es más correcto simplemente no heredar de QThread si realmente no va a implementar algún nuevo tipo especial de subprocesos y usar el QtConcurrent creado especialmente para dichos scripts, o poner la lógica en un objeto de trabajo especial heredado de QObject, colocar este último en QThread estándar y administrar Trabajador utilizando señales.

Hilo de afinidad, inicialización y sus limitaciones.


Entonces, como ya hemos descubierto, cada objeto en Qt "pertenece" a alguna secuencia. Al mismo tiempo, surge una pregunta lógica: ¿a qué, de hecho, exactamente? Las siguientes convenciones son aceptadas en Qt:

1. Todos los "hijos" de cualquier "padre" siempre viven en la misma secuencia que el padre

Esta es quizás la limitación más poderosa del modelo de flujo Qt, y los intentos de romperlo a menudo dan resultados muy extraños para el usuario. Por ejemplo, un intento de hacer setParent en un objeto que vive en otro hilo en Qt simplemente falla silenciosamente (se escribe una advertencia en la consola). Aparentemente, este compromiso se alcanzó debido al hecho de que la eliminación segura de los "niños" en el caso de la muerte de un padre que vive en otro hilo es muy poco trivial y propensa a errores difíciles de atrapar. Si desea implementar una jerarquía de objetos interactivos que viven en diferentes flujos, tendrá que organizar la eliminación usted mismo.

2. Un objeto cuyo padre no se especifica durante la creación vive en la secuencia que lo creó

Todo aquí al mismo tiempo, simple y al mismo tiempo, no siempre es obvio. Por ejemplo, en virtud de esta regla, QThread (como un objeto) vive en un hilo diferente al hilo que se controla a sí mismo (y en virtud de la regla 1, no puede poseer ninguno de los objetos creados en este hilo). O, por ejemplo, si redefine QThread :: run y crea cualquier descendiente de QObject dentro, sin tomar medidas especiales (como se discutió en el capítulo anterior), los objetos creados no responderán a las señales.

La afinidad de subprocesos se puede cambiar si es necesario llamando a QObject :: moveToThread. En virtud de la regla 1, solo se pueden mover "padres" de nivel superior (para los cuales padre == nulo), un intento de mover a cualquier "niño" se ignorará en silencio. Cuando el "padre" de nivel superior se mueve, todos sus "hijos" también se moverán a una nueva secuencia. Curiosamente, la llamada a moveToThread (nullptr) también es legal y es una forma de crear un objeto con una afinidad de hilo "nulo"; tales objetos no pueden recibir ningún mensaje.

Puede obtener el hilo de ejecución "actual" a través de una llamada a la función QThread :: currentThread (), el hilo al que está asociado el objeto, a través de una llamada a QObject :: thread ()

Una pregunta interesante sobre la atención
Tenga en cuenta que la implementación de la funcionalidad de propiedad de objetos y almacenamiento de QEventos dirigidos a ellos, obviamente, requiere que el flujo almacene los datos correspondientes en algún lugar. En el caso de Qt, la clase base QThread generalmente está involucrada en la extracción y gestión de dichos datos. Pero, ¿qué sucede si crea un QObject en algún std :: thread o llama a la función QThread :: currentThread () desde este hilo? Resulta que en este caso Qt implícitamente "detrás de escena" creará un objeto contenedor especial QAdoptedThread no propietario. Al mismo tiempo, corresponde al usuario asegurarse independientemente de que todos los objetos de dicha secuencia se eliminen antes de que se detenga la secuencia que los generó.

Hilo principal, QCoreApplication y GUI


Entre todos los hilos, Qt definitivamente seleccionará un "hilo principal", que en el caso de aplicaciones de IU también se convierte en un hilo GUI. En este hilo vive el objeto QApplication (QCoreApplication / QGuiApplication) que sirve el bucle principal de eventos orientado a trabajar con mensajes del sistema operativo. En virtud de la regla No. 2 de la sección anterior, en la práctica, el hilo "principal" será el que realmente creó el objeto QApplication, y dado que en muchos sistemas operativos el "hilo principal" tiene un significado especial, la documentación recomienda crear QApplication con el primer objeto en su conjunto. Qt programa y hazlo inmediatamente después de iniciar la aplicación (== dentro del primer hilo del proceso). Para obtener un puntero al hilo principal de la aplicación, respectivamente, puede usar una construcción del formulario QCoreApplication :: instance () -> thread (). Sin embargo, desde el punto de vista técnico, QApplication también se puede colgar en un flujo no principal () , por ejemplo, si la interfaz Qt se crea dentro de algún tipo de complemento y, en muchos casos, funcionará bien.

Debido a la regla "los objetos creados heredan el hilo actual", siempre puede trabajar con calma sin ir más allá de los límites de un hilo. Todos los objetos creados irán automáticamente al hilo "principal" para el mantenimiento, donde siempre habrá un bucle de eventos y (debido a la ausencia de otros hilos) nunca habrá problemas con la sincronización. Incluso si está trabajando con un sistema más complejo que requiere subprocesos múltiples, la mayoría de los objetos probablemente caerán en la secuencia principal, con la excepción de los pocos que se colocarán explícitamente en otro lugar. Quizás es precisamente esta circunstancia la que da lugar a la aparente "magia" en la que los objetos parecen funcionar independientemente sin ningún esfuerzo (porque la multitarea cooperativa se implementa dentro del flujo) y al mismo tiempo no requieren sincronización, bloqueo o similares (porque todo sucede en un hilo )

Además del hecho de que el subproceso "principal" es el "primero" y contiene el bucle principal de procesamiento de eventos QCoreApplication, otra limitación característica de Qt es que todos los objetos relacionados con la GUI deben "vivir" en este subproceso. Esto es en parte una consecuencia de Legacy: debido al hecho de que en algunos sistemas operativos cualquier operación con la GUI puede ocurrir solo en el hilo principal, Qt subdivide todos los objetos en "widgets" y "no widgets". El objeto de tipo widget solo puede vivir en el hilo principal, un intento de "superar" tal objeto en cualquier otro se encenderá automáticamente. En virtud de esto, incluso hay un método especial QObject :: isWidgetType () que refleja diferencias internas bastante profundas en la mecánica de trabajar con dichos objetos. Pero es interesante que en el QtQuick mucho más nuevo, donde intentaron alejarse de la muleta con isWidgetType, el mismo problema persistía

Cual es el problema En Qt5, los objetos QML ya no son widgets y se pueden representar en un hilo separado. Pero esto condujo a otro problema: dificultades de sincronización. La representación de los objetos de la interfaz de usuario es una "lectura" de su estado y debe ser coherente: si intentamos cambiar el estado de un objeto al mismo tiempo que su representación, el resultado de la "raza" resultante puede no agradarnos. Además, OpenGL en torno al cual se construye el "nuevo" gráfico Qt está extremadamente "agudizado" por el hecho de que la formación de comandos de dibujo se lleva a cabo mediante un hilo que trabaja con algún estado global: el "contexto gráfico" que solo puede cambiar como una serie de operaciones secuenciales. Simplemente no podemos dibujar simultáneamente dos objetos gráficos diferentes en la pantalla: siempre se dibujarán secuencialmente uno tras otro. Como resultado, volvemos a la misma solución: la representación de la IU se asigna a un subproceso. Sin embargo, un lector atento se dará cuenta de que este hilo no tiene que ser el hilo principal, y en Qt5 el marco realmente intentará usar un hilo de renderizado separado para esto.

Hilo de renderizado


En el marco del nuevo modelo Qt5, toda la representación de objetos tiene lugar en un hilo especialmente asignado para esto, el hilo de representación. Al mismo tiempo, para que esto tenga sentido y no se limite a simplemente cambiar de un flujo "principal" a otro, los objetos se dividen implícitamente en un "front-end" que el programador ve y generalmente un "back-end" oculto para él que realmente realiza la representación real. El back-end vive en el hilo de renderizado, mientras que el front-end, teóricamente, puede vivir en cualquier otro hilo. Se supone que el front-end realiza el trabajo útil (si lo hay) en forma de procesamiento de eventos, mientras que la función de back-end está limitada solo por la representación. En teoría, resulta ganar-ganar: la parte posterior periódicamente "sondea" el estado actual de los objetos y los dibuja en la pantalla, mientras que no puede ser "detenido" por el hecho de que algunos de los objetos estaban "pensando" demasiado mientras procesaba el evento debido al hecho de que esto el procesamiento lento ocurre en otro hilo. A su vez, el flujo del objeto no necesita esperar "respuestas" del controlador de gráficos que confirman la finalización de la representación, y diferentes objetos pueden funcionar en diferentes flujos.

Pero como ya mencioné en el capítulo anterior, dado que tenemos una secuencia que crea datos (un frente) y una secuencia que los lee (atrás), necesitamos sincronizarlos de alguna manera. Esta sincronización en Qt se realiza mediante bloqueos. El flujo donde vive el frente se suspende temporalmente, seguido de una llamada de función especial (QQuickItem :: updatePaintNode (), QQuickFramebufferObject :: Renderer :: synchronize ()) cuya única tarea es copiar el objeto relevante para la visualización desde el frente hacia atrás ". En este caso, la llamada a dicha función ocurre dentro del hilo de renderizado , pero debido al hecho de que el hilo donde vive el objeto en este momento se detiene, el usuario puede trabajar libremente con los datos del objeto como si sucediera "como de costumbre", dentro del flujo al que pertenece el objeto.

¿Está todo bien, está todo bien? Desafortunadamente, no, y los momentos bastante obvios comienzan aquí. Si tomamos un bloqueo individualmente para cada objeto, será bastante lento ya que el hilo de renderizado se verá obligado a esperar hasta que estos objetos terminen de procesar sus eventos. La secuencia "colgar" donde vive el objeto es "colgar" y renderizar. Además, será posible una "desincronización" cuando, cuando se cambien dos objetos simultáneamente, uno se dibujará en el cuadro N y el otro se dibujará solo en el cuadro N + 1. Sería preferible tomar el bloqueo solo una vez y para todos los objetos a la vez y solo cuando estemos seguros de que este bloqueo será exitoso.

¿Qué se implementó para resolver este problema en Qt? En primer lugar, se decidió que todos los objetos "gráficos" de una ventana vivirían en una secuencia. Por lo tanto, para dibujar una ventana y bloquear todos los objetos contenidos en ella, se vuelve suficiente para detener esta secuencia solo. En segundo lugar, el subproceso con objetos de interfaz de usuario inicia el bloqueo para actualizar el back-end, enviando un mensaje al subproceso de representación sobre la necesidad de sincronizarse y detenerse a sí mismo (QSGThreadedRenderLoop :: polishAndSync si alguien está interesado). Esto garantiza que el subproceso de representación nunca "esperará" una secuencia de front-end. Si de repente se "cuelga", el hilo de representación simplemente continuará dibujando el estado "antiguo" de los objetos sin recibir mensajes sobre la necesidad de actualizar. Esto realmente da lugar a errores bastante divertidos de la forma "si por alguna razón el renderizado no puede dibujar la ventana inmediatamente, el hilo principal se congela", pero en general es un compromiso razonable. Comenzando con QtQuick 2.0, varios objetos "animados" pueden incluso ser "poblados" en el hilo de renderizado para que la animación también pueda continuar funcionando si el hilo principal está "pensado".



Sin embargo, la consecuencia práctica de esta solución es que todos los objetos de la interfaz de usuario deben vivir en el mismo hilo de todos modos. En el caso de los widgets antiguos, en el hilo "principal", en el caso de los nuevos objetos Qt Quick, en el hilo del objeto QQuickWindow que los posee. La última regla es bastante elegante: para dibujar un QQuickItem necesita hacer setParent en la QQuickWindow correspondiente, que, como ya se discutió, asegura que el objeto se mueva a la secuencia correspondiente o la llamada setParent falle.

Y ahora, por desgracia, una mosca en el ungüento: aunque QQuickWindow diferente podría vivir teóricamente en diferentes flujos, en la práctica esto requiere el envío preciso de mensajes del sistema operativo a ellos y en Qt hoy no está implementado. En Qt 5.13, por ejemplo, QCoreApplication intenta comunicarse con QQuickWindow a través de sendEvent que requiere que el receptor y la parte emisora ​​estén en el mismo hilo (en lugar de postEvent que permite que los hilos sean diferentes). Por lo tanto, en la práctica, QQuickWindow solo funciona correctamente en un hilo GUI y, como resultado, todos los objetos QtQuick viven en el mismo lugar. Como resultado, a pesar de la presencia del hilo de representación, casi todos los objetos relacionados con la GUI disponibles para el usuario todavía viven en el mismo hilo de la GUI. Quizás esto cambie en Qt 6.

Además de lo anterior, vale la pena recordar que, dado que Qt funciona en muchas plataformas diferentes (incluidas las que no admiten subprocesos múltiples), el marco proporciona un número decente de fallos y, en algunos casos, la funcionalidad del hilo de renderizado es realmente realizada por el mismo hilo gui . En este caso, toda la interfaz de usuario, incluida la representación, vive en un hilo y el problema de sincronización desaparece automáticamente. La situación es similar con la interfaz de usuario más antigua, basada en widgets de estilo Qt4. Qt «» QSG_RENDER_LOOP .

Conclusión


Qt — . , , Qt .

;

  • «» , queued signals
  • «» Qt Event Loop exit()
  • Los padres y los descendientes siempre viven en la misma corriente. Solo el padre de nivel superior se puede transferir de una transmisión a otra. La violación de esta regla puede resultar en una falla silenciosa de la operación setParent o moveToThread
  • Un objeto cuyo padre no se especifica se convierte en propiedad del hilo que creó este objeto.
  • Todos los objetos de la GUI, excepto el back-end de representación, deben vivir en la secuencia de la GUI
  • El hilo GUI es aquel en el que se creó el objeto QApplication

Espero que esto lo ayude a usar Qt de manera más eficiente y no cometer errores asociados con su modelo de subprocesos múltiples

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


All Articles