Rosqueamento adequado no Qt

Qt é uma estrutura extremamente poderosa e conveniente para C ++. Mas essa conveniência tem uma desvantagem: muitas coisas no Qt acontecem ocultas ao usuário. Na maioria dos casos, a funcionalidade correspondente no Qt funciona “magicamente” e ensina o usuário a simplesmente considerar essa mágica como garantida. No entanto, quando a mágica se quebra, é extremamente difícil reconhecer e resolver um problema que de repente aparece em um nível aparentemente plano.

Este artigo é uma tentativa de sistematizar como o Qt "sob o capô" implementa o trabalho com fluxos e sobre várias armadilhas não óbvias associadas às limitações deste modelo.

O básico
Afinidade, inicialização e suas limitações de encadeamento
Thread principal, QCoreApplication e GUI
Thread de renderização
Conclusão

O básico


Vamos começar com o básico. No Qt, todos os objetos capazes de manipular sinais e slots são descendentes da classe QObject. Esses objetos, por design, são impossíveis de copiar e representam logicamente algumas entidades individuais que "conversam" entre si - reagem a determinados eventos e podem gerar eventos. Em outras palavras, o QObject no Qt implementa o padrão de atores . Se implementado corretamente, qualquer programa Qt nada mais é do que uma rede de QObjects interagindo entre si, na qual toda a lógica do programa "vive".

Além de um conjunto de QObjects, um programa Qt pode incluir objetos de dados. Esses objetos não podem gerar e receber sinais, mas podem ser copiados. Por exemplo, você pode comparar QStringList e QStringListModel entre si. Um deles é o QObject e não é copiável, mas pode interagir diretamente com os objetos da interface do usuário, o outro é um contêiner de dados copiável comum. Por sua vez, os objetos com dados são divididos em “Qt Meta-types” e todos os outros. Por exemplo, QStringList é do tipo Qt Meta, mas std :: list <std :: string> (sem gestos adicionais) não é. O primeiro pode ser usado em qualquer contexto Qt-shnom (transmitido por sinais, em QVariant etc.), mas requer um procedimento de registro especial e a classe deve ter um destruidor público, construtor de cópias e construtor padrão. O segundo são tipos arbitrários de C ++.

Vá diretamente para os threads reais


Portanto, temos "dados" condicionais e existe um "código" condicional que funciona com eles. Mas quem realmente executará esse código? No modelo Qt, a resposta a esta pergunta é definida explicitamente: cada QObject está estritamente vinculado a algum encadeamento QThread que, de fato, está envolvido em slots de manutenção e outros eventos desse objeto. Um encadeamento pode servir muitos QObjects de uma só vez, ou nenhum, mas o QObject sempre tem um encadeamento pai e é sempre exatamente um. De fato, podemos assumir que cada QThread "possui" algum conjunto de QObject. Na terminologia Qt, isso é chamado de afinidade de segmento. Vamos tentar visualizar para maior clareza:



Dentro de cada QThread há uma fila de mensagens endereçadas a objetos que esse QThread “possui”. No modelo Qt, supõe-se que, se queremos que um QObject execute alguma ação, "enviamos" uma mensagem QEvent para esse QObject:

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

Nesta chamada de thread-safe, Qt localiza o QThread ao qual o objeto receptor pertence, grava o QEvent na fila de mensagens desse thread e ativa esse thread, se necessário. Espera-se que o código em execução neste QThread em algum momento depois disso leia a mensagem da fila e execute a ação correspondente. Para que isso realmente ocorra, o código no QThread deve inserir o loop de eventos QEventLoop, criando o objeto apropriado e chamando-o de método exec () ou processEvents (). A primeira opção entra em um loop infinito de processamento de mensagens (antes que o QEventLoop receba o evento quit ()), a segunda se limita ao processamento de mensagens que foram acumuladas anteriormente na fila.



É fácil ver que os eventos para todos os objetos pertencentes a um encadeamento são processados ​​sequencialmente. Se o processamento de um evento por um encadeamento demorar muito, todos os outros objetos serão "congelados" - seus eventos serão acumulados na fila do fluxo, mas não serão processados. Para impedir que isso aconteça, o Qt oferece a possibilidade de multitarefa cooperativa - os manipuladores de eventos em qualquer lugar podem "interromper temporariamente" criando um novo QEventLoop e passando o controle para ele. Como o manipulador de eventos também foi chamado anteriormente de QEventLoop no fluxo, com essa abordagem, uma cadeia de loops de eventos "aninhados" um no outro é formada.

Algumas palavras sobre o Event Dispatcher
Estritamente falando, o QEventLoop nada mais é do que um invólucro fácil de usar sobre um primitivo de nível inferior dependente do sistema chamado Event Dispatcher e implementa a interface QAbstractEventDispatcher. É ele quem realiza a coleta e o processamento reais dos eventos. Um encadeamento pode ter apenas um QAbstractEventDispatcher e é instalado apenas uma vez. Entre outras coisas, começando com Qt5, isso permite substituir facilmente o expedidor por um mais adequado, se necessário, adicionando apenas 1 linha à inicialização do fluxo e sem tocar nos locais potencialmente numerosos em que o QEventLoop é usado.

O que está incluído no conceito de “evento” processado nesse ciclo? Bem conhecido por todos os funcionários da Qt, “sinais” é apenas um exemplo específico, QEvent :: MetaCall. Esse QEvent armazena um ponteiro para as informações necessárias para identificar a função (slot) que precisa ser chamada e seus argumentos. No entanto, além dos sinais no Qt, existem cerca de cem (!) Outros eventos, dos quais uma dúzia é reservada para eventos Qt especiais (ChildAdded, DeferredDelete, ParentChange) e o restante corresponde a várias mensagens do sistema operacional.

Por que existem tantos deles e por que era impossível ficar sem apenas sinais?
O leitor pode perguntar: por que existem tantos eventos e por que era impossível sobreviver com apenas um mecanismo de sinal conveniente e universal? O fato é que sinais diferentes podem ser processados ​​de maneira muito diferente. Por exemplo, alguns dos sinais são compactáveis ​​- se a fila já tiver uma mensagem bruta desse tipo (por exemplo, QEvent :: Paint), as mensagens subsequentes simplesmente a modificarão. Outros sinais podem ser filtrados. A presença de um pequeno número de QEvents padrão e facilmente identificáveis ​​simplifica significativamente o processamento correspondente. Além disso, o processamento de QEvent devido a um dispositivo visivelmente mais simples geralmente é realizado um pouco mais rápido do que o processamento de um sinal semelhante.

Uma das armadilhas não óbvias aqui é que no Qt, um fluxo, em geral, pode nem ter um Dispatcher e, portanto, nem um único EventLoop. Os objetos pertencentes a este fluxo não responderão aos eventos enviados a eles. Como QThread :: run (), por padrão, chama QThread :: exec () dentro do qual o EventLoop padrão é implementado, aqueles que frequentemente tentam determinar sua própria versão de run () herdada do QThread enfrentam esse problema. Um caso de uso semelhante para o QThread é, em princípio, bastante válido e até recomendado na documentação, mas contraria a idéia geral de organizar o código no Qt descrito acima e geralmente não funciona como os usuários esperam . Um erro típico nesse caso é uma tentativa de parar um QThread personalizado chamando QThread :: exit () ou quit (). Ambas as funções enviam uma mensagem para QEventLoop, mas se simplesmente não houver QEventLoop no fluxo, naturalmente não haverá ninguém para processá-las. Como resultado, usuários inexperientes que tentam "corrigir uma classe quebrada" começam a tentar usar um QThread :: terminate "funcional", o que é absolutamente impossível. Lembre-se - se você redefinir run () e não usar o loop de eventos padrão, precisará fornecer um mecanismo para sair do encadeamento - por exemplo, usando a função QThread :: requestInterruption () especialmente adicionada para isso. É mais correto, no entanto, simplesmente não herdar o QThread se você realmente não implementar algum tipo especial de encadeamento e usar o QtConcurrent especialmente criado para esses scripts ou colocar a lógica em um Object Worker especial herdado do QObject, colocar o último no QThread padrão e gerenciar Trabalhador usando sinais.

Afinidade, inicialização e suas limitações de encadeamento


Então, como já descobrimos, cada objeto no Qt "pertence" a algum fluxo. Ao mesmo tempo, surge uma pergunta lógica: para qual, de fato, exatamente? As seguintes convenções são aceitas no Qt:

1. Todos os "filhos" de qualquer "pai" sempre vivem no mesmo fluxo que o pai

Essa talvez seja a limitação mais poderosa do modelo de fluxo Qt, e as tentativas de quebrá-lo geralmente fornecem resultados muito estranhos para o usuário. Por exemplo, uma tentativa de tornar setParent em um objeto que vive em outro encadeamento no Qt simplesmente falha silenciosamente (um aviso é gravado no console). Aparentemente, esse compromisso foi alcançado devido ao fato de que a remoção segura de threads de "filhos" no caso da morte de um pai que vive em outro thread é muito pouco trivial e propensa a dificuldades para capturar bugs. Se você deseja implementar uma hierarquia de objetos interagindo vivendo em fluxos diferentes, precisará organizar a exclusão por conta própria.

2. Um objeto cujo pai não é especificado durante a criação vive no fluxo que o criou

Tudo aqui ao mesmo tempo, de maneira simples e ao mesmo tempo nem sempre é óbvio. Por exemplo, em virtude dessa regra, QThread (como um objeto) vive em um thread diferente do que ele controla (e em virtude da regra 1, ele não pode possuir nenhum dos objetos criados nesse thread). Ou, digamos, se você redefinir o QThread :: run e criar qualquer descendente do QObject dentro dele, sem tomar medidas especiais (conforme discutido no capítulo anterior), os objetos criados não responderão aos sinais.

A afinidade do encadeamento pode ser alterada, se necessário, chamando QObject :: moveToThread. Em virtude da regra 1, somente os "pais" de nível superior (para os quais os pais == nulos) podem ser movidos, uma tentativa de mover qualquer "filho" será silenciosamente ignorada. Quando o "pai" do nível superior se move, todos os seus "filhos" também se mudam para um novo fluxo. Curiosamente, a chamada para moveToThread (nullptr) também é legal e é uma maneira de criar um objeto com uma afinidade de thread "nula"; esses objetos não podem receber nenhuma mensagem.

Você pode obter o encadeamento "atual" de execução por meio de uma chamada para a função QThread :: currentThread (), o encadeamento ao qual o objeto está associado - por meio de uma chamada para QObject :: thread ()

Uma pergunta interessante sobre atenção
Observe que a implementação da funcionalidade de propriedade de objetos e armazenamento de QEvents endereçados a eles, obviamente, requer o fluxo para armazenar os dados correspondentes em algum lugar. No caso do Qt, a classe base QThread geralmente está envolvida na extração e gerenciamento de tais dados. Mas o que acontece se você criar um QObject em algum std :: thread ou chamar a função QThread :: currentThread () desse thread? Acontece que, neste caso, o Qt implicitamente "nos bastidores" criará um objeto de invólucro especial que não possui QAdoptedThread. Ao mesmo tempo, cabe ao usuário garantir de forma independente que todos os objetos desse fluxo sejam excluídos antes que o fluxo que os gerou seja parado.

Thread principal, QCoreApplication e GUI


Entre todos os threads, o Qt definitivamente destacará um "thread principal", que no caso de aplicativos de interface do usuário também se torna um thread da GUI. Nesse segmento, vive o objeto QApplication (QCoreApplication / QGuiApplication), que serve o loop principal de eventos orientado para trabalhar com mensagens do sistema operacional. Em virtude da regra 2 da seção anterior, na prática, o encadeamento "principal" será aquele que realmente criou o objeto QApplication e, como em muitos sistemas operacionais o encadeamento principal tem um significado especial, a documentação recomenda fortemente a criação de QApplication com o primeiro objeto em Qt e faça-o imediatamente após iniciar o aplicativo (== dentro do primeiro encadeamento do processo). Para obter um ponteiro para o thread principal do aplicativo, respectivamente, você pode usar uma construção do formulário QCoreApplication :: instance () -> thread (). Entretanto, puramente tecnicamente, o QApplication também pode ser suspenso em um fluxo não main () , por exemplo, se a interface Qt for criada dentro de algum tipo de plug-in e, em muitos casos, isso funcionará bem.

Devido à regra “objetos criados herdam o encadeamento atual”, você sempre pode trabalhar com calma, sem ir além dos limites de um encadeamento. Todos os objetos criados irão automaticamente para o encadeamento "principal" para manutenção, onde sempre haverá um loop de eventos e (devido à ausência de outros encadeamentos) nunca haverá problemas com a sincronização. Mesmo se você estiver trabalhando com um sistema mais complexo que requer multithreading, a maioria dos objetos provavelmente cairá no fluxo principal, com exceção dos poucos que serão explicitamente colocados em outro lugar. Talvez seja precisamente essa circunstância que dá origem à aparente “mágica” na qual os objetos parecem funcionar de forma independente, sem nenhum esforço (porque a multitarefa cooperativa é implementada dentro do fluxo) e, ao mesmo tempo, não requer sincronização, bloqueio ou similares (porque tudo acontece em um encadeamento )

Além do fato de que o encadeamento "principal" é o "primeiro" e contém o loop principal de processamento de eventos QCoreApplication, outra característica de limitação do Qt é que todos os objetos conectados à GUI devem "viver" nesse encadeamento. Isso é parcialmente uma conseqüência do Legacy: devido ao fato de que em vários sistemas operacionais qualquer operação com a GUI pode ocorrer apenas no encadeamento principal, o Qt subdivide todos os objetos em "widgets" e "não-widgets". O objeto do tipo widget pode viver apenas no encadeamento principal, uma tentativa de "superar" esse objeto em qualquer outro será acionada automaticamente. Em virtude disso, existe até um método QObject :: isWidgetType () especial que reflete diferenças internas bastante profundas na mecânica de trabalhar com esses objetos. Mas é interessante que no QtQuick, muito mais recente, onde eles tentaram fugir da muleta com o isWidgetType, o mesmo problema permaneceu.

Qual é o problema? No Qt5, os objetos QML não são mais widgets e podem ser renderizados em um thread separado. Mas isso levou a outro problema - dificuldades de sincronização. A renderização de objetos da interface do usuário é uma “leitura” de seu estado e deve ser consistente: se tentarmos alterar o estado de um objeto ao mesmo tempo que sua renderização, o resultado da “corrida” resultante pode não nos agradar. Além disso, o OpenGL em torno do qual os "novos" gráficos Qt são construídos é extremamente "aguçado" ao fato de que a formação de comandos de desenho é realizada por um thread que trabalha com algum estado global - o "contexto gráfico" que só pode mudar como uma série de operações seqüenciais. Simplesmente não podemos desenhar simultaneamente dois objetos gráficos diferentes na tela - eles sempre serão desenhados sequencialmente um após o outro. Como resultado, retornamos à mesma solução - renderizando a interface do usuário é atribuída a um thread. Um leitor atento, no entanto, notará que esse thread não precisa ser o thread principal - e no Qt5 a estrutura realmente tentará usar um thread de Renderização separado para isso.

Thread de renderização


Na estrutura do novo modelo Qt5, toda a renderização de objetos ocorre em um thread especialmente alocado para esse thread de renderização. Ao mesmo tempo, para que isso faça sentido e não se limite a simplesmente alternar de um fluxo "principal" para outro, os objetos são implicitamente divididos em um "front-end" que o programador vê e, geralmente, um "back-end" oculto dele que realmente executa a renderização real. O back-end vive no segmento de renderização, enquanto o front-end, teoricamente, pode viver em qualquer outro segmento. Supõe-se que o front-end execute o trabalho útil (se houver) na forma de processamento de eventos, enquanto a função de back-end é limitada apenas pela renderização. Em teoria, acontece vantajoso para as duas partes: a parte traseira periodicamente “consulta” o estado atual dos objetos e os desenha na tela, enquanto não pode ser “interrompida” pelo fato de que alguns dos objetos estavam “pensando” demais durante o processamento do evento devido ao fato de que isso O processamento lento ocorre em outro thread. Por sua vez, o fluxo do objeto não precisa aguardar "respostas" do driver gráfico, confirmando a conclusão da renderização, e objetos diferentes podem funcionar em fluxos diferentes.

Mas, como já mencionei no capítulo anterior, como temos um fluxo que cria dados (uma frente) e um fluxo que os lê (costas), precisamos de alguma forma sincronizá-los. Essa sincronização no Qt é feita por bloqueios. O fluxo em que a frente fica temporariamente suspenso, seguido por uma chamada de função especial (QQuickItem :: updatePaintNode (), QQuickFramebufferObject :: Renderer :: synchronize ()) cuja única tarefa é copiar o objeto relevante para visualizar o estado de frente para trás " Nesse caso, a chamada dessa função ocorre dentro do encadeamento de renderização , mas devido ao fato de que o encadeamento em que o objeto vive neste momento está parado, o usuário pode trabalhar livremente com os dados do objeto como se isso acontecesse "como sempre", dentro do fluxo ao qual o objeto pertence.

Está tudo bem, está tudo bem? Infelizmente, não, e momentos bastante óbvios começam aqui. Se usarmos um bloqueio individualmente para cada objeto, será bastante lento, pois o encadeamento de renderização será forçado a esperar até que esses objetos concluam o processamento de seus eventos. O fluxo "travar" onde o objeto vive é "travar" e renderizar. Além disso, uma "dessincronização" será possível quando, quando dois objetos forem alterados simultaneamente, um será desenhado no quadro N e o outro será desenhado apenas no quadro N + 1. Seria preferível usar o bloqueio apenas uma vez e para todos os objetos de uma só vez e somente quando tivermos certeza de que esse bloqueio será bem-sucedido.

O que foi implementado para resolver este problema no Qt? Primeiramente, foi decidido que todos os objetos "gráficos" de uma janela viverão em um fluxo. Assim, para desenhar uma janela e bloquear todos os objetos nela contidos, torna-se suficiente interromper esse fluxo sozinho. Em segundo lugar, o encadeamento com objetos de interface do usuário inicia o bloqueio para atualizar o backend, enviando uma mensagem para o encadeamento de renderização sobre a necessidade de sincronização e parada (QSGThreadedRenderLoop :: polishAndSync, se alguém estiver interessado). Isso garante que o encadeamento de renderização nunca “espere” por um fluxo de front-end. Se de repente "travar", o segmento de renderização continuará simplesmente desenhando o estado "antigo" dos objetos sem receber mensagens sobre a necessidade de atualização. Isso realmente dá origem a bugs bastante divertidos do formulário "se, por algum motivo, a renderização não puder abrir a janela imediatamente, o thread principal congela", mas, em geral, é um compromisso razoável. Começando com o QtQuick 2.0, vários objetos "animados" podem até ser "preenchidos" no thread de renderização, para que a animação também possa continuar funcionando se o thread principal for "pensado".



No entanto, a conseqüência prática dessa solução é que todos os objetos da interface do usuário devem viver no mesmo encadeamento de qualquer maneira. No caso de widgets antigos, no segmento "principal", no caso de novos objetos Qt Quick, no segmento de objeto QQuickWindow que os possui. A última regra é superada de maneira elegante - para desenhar um QQuickItem, ele precisa tornar setParent no QQuickWindow correspondente, o que, como já discutido, garante que o objeto seja movido para o fluxo correspondente ou a chamada setParent falhe.

E agora, infelizmente, uma mosca na pomada: embora o QQuickWindow diferente possa teoricamente viver em fluxos diferentes, na prática isso requer o envio preciso de mensagens do sistema operacional para eles e no Qt hoje ele não está implementado. No Qt 5.13, por exemplo, o QCoreApplication tenta se comunicar com o QQuickWindow via sendEvent, exigindo que o destinatário e a parte remetente estejam no mesmo encadeamento (em vez de postEvent, que permite que os encadeamentos sejam diferentes). Portanto, na prática, o QQuickWindow só funciona corretamente em um thread da GUI e, como resultado, todos os objetos QtQuick vivem no mesmo local. Como resultado, apesar da presença do encadeamento de renderização, quase todos os objetos relacionados à GUI disponíveis para o usuário ainda vivem no mesmo encadeamento da GUI. Talvez isso mude no quarto trimestre.

Além do acima, também vale lembrar que, como o Qt funciona em muitas plataformas diferentes (incluindo aquelas que não suportam multithreading), a estrutura fornece um número decente de fallbacks e, em alguns casos, a funcionalidade do thread de renderização é realmente executada pelo mesmo thread da GUI . Nesse caso, a interface do usuário inteira, incluindo a renderização, vive em um thread e o problema de sincronização desaparece automaticamente. A situação é semelhante à interface do usuário mais antiga, baseada no widget no estilo Qt4. Se desejar, você pode fazer o Qt funcionar nesse modo "single-threaded", configurando a variável de ambiente QSG_RENDER_LOOP para a opção apropriada.

Conclusão


O Qt é uma estrutura enorme e complexa, e trabalhar com threads reflete parte dessa complexidade. Mas ele foi projetado com muito cuidado, lógica e competência; portanto, ao entender várias idéias-chave com fluxos no Qt, é bastante simples trabalhar sem erros.

Deixe-me lembrá-lo novamente dos pontos principais;

  • Cada objeto possui um encadeamento que o possui, executando manipuladores de todos os eventos que ocorrem com o objeto, incluindo sinais na fila
  • Se o encadeamento "proprietário" do objeto não executar o Qt Event Loop, os objetos pertencentes a ele não receberão nenhuma mensagem e o encadeamento em si não responderá às tentativas de solicitar a saída ()
  • Pais e descendentes sempre vivem no mesmo riacho. Somente o pai de nível superior pode ser transferido de fluxo em fluxo. A violação desta regra pode resultar em falha silenciosa da operação setParent ou moveToThread
  • Um objeto cujo pai não está especificado se torna propriedade do encadeamento que esse objeto criou.
  • Todos os objetos da GUI, exceto o backend de renderização, devem viver no fluxo da GUI
  • O segmento da GUI é aquele em que o objeto QApplication foi criado

Espero que isso ajude você a usar o Qt com mais eficiência e não cometa erros associados ao seu modelo multiencadeado.

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


All Articles