Qt adalah kerangka kerja yang sangat kuat dan nyaman untuk C ++. Tetapi kemudahan ini memiliki kerugian: cukup banyak hal di Qt terjadi tersembunyi dari pengguna. Dalam kebanyakan kasus, fungsionalitas yang sesuai di Qt bekerja “secara ajaib” dan itu mengajarkan pengguna untuk hanya menerima keajaiban ini begitu saja. Namun, ketika sihir tetap rusak, sangat sulit untuk mengenali dan memecahkan masalah yang tiba-tiba muncul pada tingkat yang tampaknya datar.
Artikel ini adalah upaya untuk mensistematisasikan bagaimana Qt "di bawah tenda" mengimplementasikan bekerja dengan aliran dan tentang sejumlah perangkap tersembunyi yang terkait dengan keterbatasan model ini.
Dasar-dasarnyaAfinitas utas, inisialisasi, dan batasannyaUtas utama, QCoreApplication, dan GUIRendering threadKesimpulanDasar-dasarnya
Mari kita mulai dengan dasar-dasarnya. Dalam Qt, objek apa pun yang mampu menangani sinyal dan slot adalah turunan dari kelas QObject. Objek-objek ini dengan desain tidak dapat disalin dan secara logis mewakili beberapa entitas individu yang “berbicara” satu sama lain - bereaksi terhadap peristiwa tertentu dan dapat dengan sendirinya menghasilkan peristiwa. Dengan kata lain, QObject di Qt mengimplementasikan
pola Aktor . Jika diimplementasikan dengan benar, setiap program Qt pada dasarnya tidak lebih dari jaringan QObjects yang saling berinteraksi di mana semua logika program "hidup".
Selain satu set QObjects, program Qt dapat menyertakan objek data. Objek-objek ini tidak dapat menghasilkan dan menerima sinyal, tetapi dapat disalin. Misalnya, Anda dapat membandingkan QStringList dan QStringListModel di antara mereka. Salah satunya adalah QObject dan tidak dapat disalin, tetapi dapat langsung berinteraksi dengan objek UI, yang lainnya adalah wadah yang dapat disalin secara teratur untuk data. Pada gilirannya, objek dengan data dibagi menjadi "Qt Meta-types" dan yang lainnya. Sebagai contoh, QStringList adalah tipe-Qt Meta, tetapi std :: list <std :: string> (tanpa gerakan tambahan) tidak. Yang pertama dapat digunakan dalam konteks Qt-shnom apa saja (ditransmisikan melalui sinyal, berbaring di QVariant, dll.), Tetapi memerlukan prosedur pendaftaran khusus dan kelas harus memiliki destruktor publik, copy constructor, dan konstruktor default. Yang kedua adalah tipe C ++ sewenang-wenang.
Pindah mulus ke utas sebenarnya
Jadi, kami memiliki "data" bersyarat dan ada "kode" bersyarat yang bekerja dengannya. Tetapi siapa yang benar-benar akan menjalankan kode ini? Dalam model Qt, jawaban untuk pertanyaan ini ditetapkan secara eksplisit: setiap QObject secara ketat terikat pada beberapa utas QThread yang, pada kenyataannya, terlibat dalam slot servis dan peristiwa lain dari objek ini. Satu utas dapat melayani banyak QObject sekaligus, atau tidak sama sekali, tetapi QObject selalu memiliki utas induk dan selalu tepat satu. Faktanya, kita dapat mengasumsikan bahwa setiap QThread "memiliki" beberapa set QObject. Dalam terminologi Qt, ini disebut Thread Affinity. Mari kita coba visualisasikan untuk kejelasan:

Di dalam setiap QThread adalah antrian pesan yang ditujukan ke objek yang dimiliki oleh QThread ini. Model Qt mengasumsikan bahwa jika kita ingin QObject mengambil tindakan, maka kita akan "mengirim" pesan QEvent ke QObject ini:
QCoreApplication::postEvent(QObject *receiver, QEvent *event, int priority);
Dalam panggilan aman thread ini, Qt menemukan QThread yang menjadi objek penerima, menulis QEvent ke antrian pesan utas ini, dan membangunkan utas ini jika perlu. Diharapkan kode yang berjalan di QThread ini di beberapa titik setelah itu akan membaca pesan dari antrian dan melakukan tindakan yang sesuai. Agar hal ini benar-benar terjadi, kode di QThread harus masuk ke loop acara QEventLoop, membuat objek yang sesuai dan memanggilnya dengan metode exec () atau metode processEvents (). Opsi pertama memasuki loop pemrosesan pesan tanpa akhir (sebelum QEventLoop menerima acara berhenti ()), yang kedua terbatas untuk memproses pesan yang sebelumnya terakumulasi dalam antrian.

Sangat mudah untuk melihat bahwa peristiwa untuk semua objek milik satu utas diproses secara berurutan. Jika pemrosesan suatu peristiwa oleh utas membutuhkan waktu yang lama, maka semua objek lain akan "dibekukan" - acara mereka akan terakumulasi dalam antrian aliran, tetapi tidak akan diproses. Untuk mencegah hal ini terjadi, Qt menyediakan kemungkinan multitasking kooperatif - event handler di mana saja dapat "sementara menginterupsi" dengan membuat QEventLoop baru dan memberikan kontrol padanya. Karena event handler sebelumnya juga dipanggil dari QEventLoop di stream, dengan pendekatan ini, rantai loop acara "bersarang" di satu sama lain terbentuk.
Beberapa kata tentang Event DispatcherSebenarnya, QEventLoop tidak lebih dari pembungkus ramah-pengguna atas primitif yang bergantung pada sistem tingkat rendah yang disebut Event Dispatcher dan mengimplementasikan antarmuka QAbstractEventDispatcher. Dialah yang melakukan pengumpulan aktual dan pengolahan acara. Utas hanya dapat memiliki satu QAbstractEventDispatcher dan hanya dipasang sekali. Antara lain, dimulai dengan Qt5, ini memungkinkan Anda untuk dengan mudah
mengganti dispatcher dengan yang lebih cocok jika perlu dengan menambahkan hanya 1 baris ke inisialisasi aliran dan tanpa menyentuh banyak tempat yang berpotensi banyak tempat QEventLoop digunakan.
Apa yang termasuk dalam konsep "peristiwa" yang diproses dalam siklus semacam itu? Terkenal bagi semua karyawan Qt, "sinyal" hanyalah satu contoh khusus, QEvent :: MetaCall. QEvent seperti menyimpan pointer ke informasi yang diperlukan untuk mengidentifikasi fungsi (slot) yang perlu dipanggil dan argumennya. Namun, selain sinyal di Qt, ada sekitar seratus (!) Peristiwa lainnya, di mana selusin dicadangkan untuk acara Qt khusus (ChildAdded, DeferredDelete, ParentChange) dan sisanya sesuai dengan berbagai pesan dari sistem operasi.
Mengapa ada begitu banyak dari mereka dan mengapa itu tidak mungkin dilakukan tanpa sinyal?Pembaca mungkin bertanya: mengapa ada begitu banyak peristiwa dan mengapa tidak mungkin bertahan hanya dengan satu mekanisme sinyal yang nyaman dan universal? Faktanya adalah bahwa sinyal yang berbeda dapat diproses dengan sangat berbeda. Misalnya, beberapa sinyal dapat dikompresi - jika antrian sudah memiliki satu pesan mentah dari jenis ini (katakanlah QEvent :: Paint), maka pesan berikutnya cukup memodifikasinya. Sinyal lain dapat disaring. Kehadiran sejumlah kecil QEvents standar dan mudah diidentifikasi secara signifikan menyederhanakan pemrosesan yang sesuai. Selain itu, pemrosesan QEvent karena perangkat yang terasa lebih sederhana biasanya dilakukan agak lebih cepat daripada pemrosesan sinyal yang sama.
Salah satu perangkap tersembunyi di sini adalah bahwa di Qt, streaming, secara umum, bahkan mungkin tidak memiliki Dispatcher, dan karena itu bukan EventLoop tunggal. Objek yang termasuk dalam aliran ini tidak akan menanggapi acara yang dikirimkan kepadanya. Karena QThread :: run () oleh panggilan default QThread :: exec () di mana EventLoop standar baru saja dilaksanakan, mereka yang sering mencoba untuk menentukan versi run () mereka sendiri yang diwarisi dari QThread sering menghadapi masalah ini. Kasus penggunaan yang serupa untuk QThread pada prinsipnya cukup valid dan bahkan direkomendasikan dalam dokumentasi, tetapi bertentangan dengan ide umum mengatur kode dalam Qt yang dijelaskan di atas dan seringkali tidak berfungsi seperti yang
diharapkan pengguna. Kesalahan umum dalam kasus ini adalah upaya untuk menghentikan QThread kustom dengan memanggil QThread :: exit () atau exit (). Kedua fungsi ini mengirim pesan ke QEventLoop, tetapi jika tidak ada QEventLoop di stream, maka secara alami tidak ada yang memprosesnya. Akibatnya, pengguna yang tidak berpengalaman mencoba untuk "memperbaiki kelas yang rusak" mulai mencoba untuk menggunakan "bekerja" QThread :: terminate, yang sama sekali tidak mungkin. Ingat - jika Anda mendefinisikan ulang run () dan tidak menggunakan loop acara standar, maka Anda harus menyediakan mekanisme untuk keluar dari utas sendiri - misalnya, menggunakan fungsi QThread :: requestInterrupt () yang ditambahkan secara khusus untuk ini. Akan tetapi, lebih tepat untuk tidak mewarisi dari QThread jika Anda tidak benar-benar akan mengimplementasikan beberapa jenis utas baru dan menggunakan QtConcurrent yang dibuat khusus untuk skrip tersebut, atau menempatkan logika dalam Obyek Pekerja khusus yang diwarisi dari QObject, letakkan yang terakhir dalam QThbread standar dan kelola Pekerja menggunakan sinyal.
Afinitas utas, inisialisasi, dan batasannya
Jadi, seperti yang telah kita ketahui, setiap objek di Qt "milik" ke aliran tertentu. Pada saat yang sama, muncul pertanyaan logis: yang sebenarnya? Kebaktian berikut diterima di Qt:
1. Semua "anak-anak" dari setiap "orang tua" selalu hidup dalam aliran yang sama dengan orang tuaIni mungkin batasan paling kuat dari model aliran Qt, dan upaya untuk memutusnya sering memberikan hasil yang sangat aneh bagi pengguna. Misalnya, upaya membuat setParent pada objek yang hidup di utas lain di Qt gagal diam-diam (peringatan ditulis ke konsol). Tampaknya, kompromi ini tercapai karena fakta bahwa penghapusan “anak-anak” yang aman dari benang dalam kasus kematian orang tua yang tinggal di utas lain adalah sangat tidak sepele dan cenderung sulit untuk menangkap serangga. Jika Anda ingin menerapkan hierarki objek yang berinteraksi yang hidup dalam aliran yang berbeda, Anda harus mengatur penghapusan sendiri.
2. Objek yang orang tuanya tidak ditentukan selama pembuatan tinggal di aliran yang membuatnyaSemua yang ada di sini pada saat yang sama dan sederhana dan pada saat yang sama tidak selalu jelas. Misalnya, berdasarkan aturan ini, QThread (sebagai objek) hidup di utas berbeda dari utas yang dikontrol sendiri (dan berdasarkan aturan 1, ia tidak dapat memiliki objek apa pun yang dibuat dalam utas ini). Atau, katakanlah, jika Anda mendefinisikan ulang QThread :: menjalankan dan membuat turunan QObject di dalamnya, maka tanpa mengambil tindakan khusus (seperti yang dibahas pada bab sebelumnya), objek yang dibuat tidak akan menanggapi sinyal.
Afinitas utas dapat diubah jika perlu dengan memanggil QObject :: moveToThread. Berdasarkan aturan 1, hanya "orang tua" tingkat atas (yang orang tuanya == null) dapat dipindahkan, upaya untuk memindahkan "anak" mana pun akan diabaikan dalam hati. Ketika "orang tua" tingkat atas bergerak, semua "anak-anaknya" juga akan pindah ke aliran baru. Anehnya, panggilan untuk memindahkanToThread (nullptr) juga sah dan merupakan cara untuk membuat objek dengan afinitas utas “nol”; objek semacam itu tidak dapat menerima pesan apa pun.
Anda bisa mendapatkan utas eksekusi "saat ini" melalui panggilan ke fungsi QThread :: currentThread (), utas yang terkait dengan objek - melalui panggilan ke QObject :: utas ()
Sebuah pertanyaan menarik tentang perhatianPerhatikan bahwa implementasi fungsional kepemilikan objek dan penyimpanan QEvents yang ditujukan kepada mereka, jelas, membutuhkan aliran untuk menyimpan data terkait di suatu tempat. Dalam kasus Qt, kelas dasar QThread biasanya terlibat dalam ekstraksi dan pengelolaan data tersebut. Tetapi apa yang terjadi jika Anda membuat QObject di beberapa std :: thread atau memanggil fungsi QThread :: currentThread () dari utas ini? Ternyata dalam hal ini Qt secara implisit "di belakang layar" akan membuat objek pembungkus khusus yang tidak memiliki QAdoptedThread. Pada saat yang sama, adalah kewajiban pengguna untuk secara independen memastikan bahwa semua objek dalam aliran tersebut dihapus sebelum aliran yang dihasilkannya dihentikan.
Utas utama, QCoreApplication, dan GUI
Di antara semua utas, Qt pasti akan memilih satu "utas utama", yang dalam hal aplikasi UI juga menjadi utas GUI. Dalam utas ini tinggal objek QApplication (QCoreApplication / QGuiApplication) yang melayani loop peristiwa utama yang berorientasi untuk bekerja dengan pesan dari sistem operasi. Berdasarkan aturan No. 2 dari bagian sebelumnya, dalam praktiknya, utas “utama” akan menjadi yang benar-benar membuat objek QApplication, dan karena dalam banyak sistem operasi “utas utama” memiliki makna khusus, dokumentasi sangat menyarankan untuk membuat Aplikasi Q dengan objek pertama di keseluruhan Qt memprogram dan melakukannya segera setelah memulai aplikasi (== di dalam utas pertama dalam proses). Untuk mendapatkan pointer ke utas utama aplikasi, masing-masing, Anda dapat menggunakan konstruksi formulir QCoreApplication :: instance () -> utas (). Namun, murni secara teknis, aplikasi QApplication
juga dapat digantung di non-main () , misalnya, jika antarmuka Qt dibuat di dalam semacam plug-in dan dalam banyak kasus ini akan berfungsi dengan baik.
Karena aturan "objek yang dibuat mewarisi utas saat ini", Anda selalu dapat bekerja dengan tenang tanpa melampaui batas satu utas. Semua objek yang dibuat akan secara otomatis pergi ke utas “utama” untuk diservis, di mana akan selalu ada loop peristiwa dan (karena tidak ada utas lainnya) tidak akan pernah ada masalah dengan sinkronisasi. Bahkan jika Anda bekerja dengan sistem yang lebih kompleks yang membutuhkan multithreading, sebagian besar objek kemungkinan besar akan jatuh ke aliran utama, dengan pengecualian beberapa yang akan secara eksplisit ditempatkan di tempat lain. Mungkin justru keadaan inilah yang memunculkan "keajaiban" di mana benda-benda tampak bekerja secara mandiri tanpa usaha (karena multitasking kooperatif diterapkan dalam aliran) dan pada saat yang sama tidak memerlukan sinkronisasi, pemblokiran, atau sejenisnya (karena semuanya terjadi dalam satu utas )
Selain fakta bahwa utas "utama" adalah utas "pertama" dan berisi loop pemrosesan acara QCoreApplication utama, karakteristik batasan lain dari Qt adalah bahwa semua objek yang terhubung dengan GUI harus "hidup" di utas ini. Ini sebagian merupakan konsekuensi dari Legacy: karena fakta bahwa dalam sejumlah sistem operasi, operasi dengan GUI hanya dapat terjadi di utas utama, Qt membagi semua objek menjadi "widget" dan "non-widget". Objek tipe widget hanya dapat hidup di utas utama, upaya untuk "lebih besar" dari objek lain yang secara otomatis akan menyala. Berdasarkan hal ini, bahkan ada metode QObject :: isWidgetType () khusus yang mencerminkan perbedaan internal yang agak dalam dalam mekanisme kerja dengan objek tersebut. Tetapi menarik bahwa di QtQuick yang jauh lebih baru, di mana mereka mencoba melepaskan diri dari kruk dengan isWidgetType, masalah yang sama tetap ada
Ada apa? Di Qt5, objek QML bukan lagi widget dan dapat dirender di utas terpisah. Tapi ini menyebabkan masalah lain - kesulitan sinkronisasi. Rendering objek UI adalah "pembacaan" dari keadaannya dan harus konsisten: jika kita mencoba mengubah keadaan suatu objek pada saat bersamaan dengan renderingnya, hasil dari "balapan" yang dihasilkan mungkin tidak menyenangkan kita. Selain itu, OpenGL di mana Qt grafis "baru" dibangun sangat "dipertajam" dengan fakta bahwa pembentukan perintah menggambar dilakukan oleh satu utas yang bekerja dengan beberapa negara global - "konteks grafis" yang hanya dapat berubah sebagai serangkaian operasi berurutan. Kita tidak bisa secara bersamaan menggambar dua objek grafik yang berbeda di layar - mereka akan selalu digambar berurutan. Sebagai hasilnya, kami kembali ke solusi yang sama - rendering UI ditugaskan ke satu utas. Namun, pembaca yang penuh perhatian akan melihat bahwa utas ini tidak harus menjadi utas utama - dan pada Qt5 kerangka kerja akan benar-benar mencoba menggunakan utas Rendering terpisah untuk ini.
Rendering thread
Dalam kerangka model Qt5 baru, semua rendering objek terjadi di utas yang dialokasikan khusus untuk ini, utas rendering. Selain itu, agar masuk akal dan tidak terbatas hanya beralih dari satu "utama" aliran ke yang lain, objek secara implisit dibagi menjadi "front-end" yang programmer lihat dan biasanya "back-end" tersembunyi darinya yang benar-benar melakukan rendering yang sebenarnya. Ujung belakang hidup di utas render, sedangkan ujung depan, secara teoritis, dapat hidup di utas lainnya. Diasumsikan bahwa front-end melakukan pekerjaan yang berguna (jika ada) dalam bentuk pemrosesan peristiwa, sedangkan fungsi back-end hanya dibatasi oleh rendering. Secara teori, ternyata win-win: punggung secara berkala "polling" keadaan saat ini dari objek dan menarik mereka di layar, sementara itu tidak dapat "dihentikan" oleh fakta bahwa beberapa objek "berpikir" terlalu banyak saat memproses acara karena fakta bahwa ini pemrosesan lambat terjadi di utas lain. Pada gilirannya, aliran objek tidak perlu menunggu "jawaban" dari driver grafis yang mengkonfirmasi penyelesaian render, dan objek yang berbeda dapat bekerja dalam aliran yang berbeda.
Tetapi seperti yang telah saya sebutkan di bab sebelumnya, karena kita memiliki aliran yang membuat data (depan) dan aliran yang membacanya (belakang), kita perlu menyinkronkannya. Sinkronisasi di Qt ini dilakukan dengan kunci. Aliran tempat bagian depan ditangguhkan sementara, diikuti oleh pemanggilan fungsi khusus (QQuickItem :: updatePaintNode (), QQuickFramebufferObject :: Renderer :: sinkronisasi ()) yang tugasnya hanya menyalin objek yang relevan untuk memvisualisasikan keadaan dari depan ke belakang. ". Dalam kasus ini, pemanggilan fungsi semacam itu terjadi
di dalam utas render , tetapi karena fakta bahwa utas tempat objek saat ini dihentikan, pengguna dapat dengan bebas bekerja dengan data objek seolah-olah itu terjadi "seperti biasa", di dalam aliran tempat benda tersebut berada.
Apakah semuanya baik-baik saja, semuanya baik-baik saja? Sayangnya, tidak, dan saat-saat yang sangat tidak jelas mulai di sini. Jika kita mengambil kunci secara terpisah untuk masing-masing objek, itu akan agak lambat karena thread rendering akan dipaksa untuk menunggu sampai objek ini selesai memproses acara mereka. Aliran "hang" di mana objek tinggal adalah "hang" dan rendering. Selain itu, "desync" akan menjadi mungkin ketika, ketika dua objek diubah secara bersamaan, satu akan digambar dalam frame N dan yang lainnya akan digambar hanya dalam frame N +1. Akan lebih baik untuk mengambil kunci hanya sekali dan untuk semua objek sekaligus dan hanya ketika kami yakin bahwa kunci ini akan berhasil.
Apa yang diterapkan untuk mengatasi masalah ini di Qt? Pertama, diputuskan bahwa semua objek "grafik" dari satu jendela akan hidup dalam satu aliran. Jadi, untuk menggambar jendela dan mengambil kunci pada semua objek yang terkandung di dalamnya, itu menjadi cukup untuk menghentikan aliran ini sendirian. Kedua, utas dengan objek UI memulai kunci untuk memperbarui back-end, mengirim pesan ke render utas tentang perlunya menyinkronkan dan menghentikan dirinya sendiri (QSGThreadedRenderLoop :: polishAndSync jika ada yang tertarik). Ini memastikan bahwa utas render tidak akan pernah "menunggu" untuk aliran front-end. Jika tiba-tiba "hang", utas render hanya akan terus menggambar keadaan "lama" dari objek tanpa menerima pesan tentang perlunya memperbarui. Ini benar-benar menimbulkan bug yang cukup lucu dari bentuk "jika karena alasan tertentu rendering tidak dapat langsung menggambar jendela, utas utama membeku", tetapi secara umum itu adalah kompromi yang masuk akal. Dimulai dengan QtQuick 2.0,
sejumlah objek "animasi" bahkan dapat "diisi" di render thread sehingga animasi juga dapat terus bekerja jika utas utama "dipikirkan".

Namun, konsekuensi praktis dari solusi ini adalah bahwa semua objek UI harus hidup di utas yang sama. Dalam kasus widget lama, di utas "utama", dalam kasus objek Qt Quick baru, di utas objek QQuickWindow yang memilikinya. Aturan terakhir cukup dipukuli secara elegan - untuk menggambar QQuickItem, ia perlu membuat setParent ke QQuickWindow yang sesuai, seperti yang sudah dibahas, menjamin bahwa objek bergerak ke aliran yang sesuai atau panggilan setParent gagal.
Dan sekarang, sayangnya, lalat di salep: meskipun QQuickWindow yang berbeda secara teoritis dapat hidup di aliran yang berbeda, dalam praktiknya ini membutuhkan pengiriman pesan yang akurat dari sistem operasi kepada mereka dan di Qt hari ini tidak diterapkan. Pada Qt 5.13, misalnya, QCoreApplication mencoba berkomunikasi dengan QQuickWindow melalui sendEvent yang mengharuskan penerima dan pihak pengirim berada di utas yang sama (bukan postEvent yang memungkinkan utas berbeda). Oleh karena itu, dalam praktiknya, QQuickWindow hanya berfungsi dengan benar di utas GUI, dan sebagai hasilnya, semua objek QtQuick hidup di tempat yang sama. Akibatnya, meskipun ada utas render, hampir semua objek terkait GUI yang tersedia untuk pengguna masih hidup di utas GUI yang sama. Mungkin ini akan berubah di Qt 6.
Selain hal di atas, perlu juga diingat bahwa karena Qt bekerja pada banyak platform yang berbeda (termasuk yang tidak mendukung multithreading), framework ini menyediakan sejumlah fallback yang layak dan dalam beberapa kasus fungsionalitas rendering thread sebenarnya dilakukan oleh gui thread yang sama. . Dalam hal ini, seluruh UI, termasuk rendering, hidup dalam satu utas dan masalah sinkronisasi secara otomatis menghilang. Situasinya mirip dengan UI berbasis widget Qt4 gaya lama. Qt «» QSG_RENDER_LOOP .
Kesimpulan
Qt — . , , Qt .
;
- «» , queued signals
- «» Qt Event Loop exit()
- Orang tua dan keturunan selalu hidup dalam aliran yang sama. Hanya orang tua tingkat atas yang dapat ditransfer dari streaming ke streaming. Pelanggaran aturan ini dapat mengakibatkan kegagalan diam dari operasi setParent atau moveToThread
- Objek yang orang tuanya tidak ditentukan menjadi properti utas yang dibuat objek ini.
- Semua objek GUI kecuali untuk rendering back-end harus hidup dalam aliran GUI
- Utas GUI adalah yang di mana objek QApplication dibuat
Saya harap ini akan membantu Anda menggunakan Qt lebih efisien dan tidak membuat kesalahan yang terkait dengan model multi-utasnya.