Bibliothèque de widgets asynchrones Qt-async

Bonne journée à tous. Je veux parler un peu de mon projet qt-async , peut-être qu'il semblera intéressant ou même utile à quelqu'un.

L'asynchronie et le multithreading font depuis longtemps partie intégrante de la vie quotidienne des développeurs. De nombreuses langues et bibliothèques modernes sont conçues pour une utilisation asynchrone. Le langage C ++ évolue également lentement dans cette direction - std :: thread, std :: promise / future sont apparus, ils sont sur le point d'apporter des coroutines et des réseaux. La bibliothèque Qt n'est pas en reste, offrant ses analogues QThread, QRunnable, QThreadPool, QFuture, etc. En même temps, je n'ai pas trouvé de widgets pour afficher des actions asynchrones dans Qt (peut-être que je regardais mal, correct si je me trompe).

J'ai donc décidé de combler cette lacune et d'essayer de mettre en œuvre un tel widget moi-même. Le développement multithread est une entreprise complexe mais intéressante.

Avant de procéder à l'implémentation du widget, vous devez décrire le modèle qu'il présentera à l'utilisateur sous forme de fenêtre. Dans sa forme la plus générale, le fonctionnement du widget me semble être le suivant: à un moment donné, l'utilisateur ou le système démarre une opération asynchrone. À ce stade, le widget affiche la progression de l'opération ou simplement une indication de l'opération. Facultativement, l'utilisateur a la possibilité d'annuler l'opération. Ensuite, l'opération asynchrone se termine de deux manières: soit une erreur s'est produite et notre widget l'affiche, soit le widget affiche le résultat de l'opération réussie.

Ainsi, notre modèle peut être dans l'un des trois états suivants:

  1. Progression - une opération asynchrone est en cours
  2. Erreur - échec de l'opération asynchrone
  3. Valeur - opération asynchrone terminée avec succès

Dans chacun des états, le modèle doit stocker les données correspondantes, j'ai donc appelé le modèle AsyncValue. Il est important de noter que le fonctionnement asynchrone lui-même ne fait pas partie de notre modèle, il ne fait que changer son état. Il s'avère que AsyncValue peut être utilisé avec n'importe quelle bibliothèque asynchrone, en observant un modèle d'utilisation simple:

  1. Au début de l'opération asynchrone, définissez AsuncValue sur Progress
  2. À la fin - soit en erreur, soit en valeur, selon le succès de l'opération
  3. Facultativement, pendant l'opération, vous pouvez mettre à jour les données de progression et écouter l'indicateur Stop si l'utilisateur a la possibilité d'arrêter l'opération.

Voici un exemple schématique utilisant QRunnable:

class MyRunnable : public QRunnable { public: MyRunnable(AsyncValue& value) : m_value(value) {} void run() final { m_value.setProgress(...); // do calculation if (success) m_value.setValue(...); else m_value.setError(...); } private: AsyncValue& m_value; } 

Le même schéma pour travailler avec std :: thread:

 AsyncValue value; std::thread thread([&value] () { value.setProgress(...); // do calculation if (success) value.setValue(...); else value.setError(...); }); 

Ainsi, la première version de notre classe pourrait ressembler à ceci:

 template <typename ValueType_t, typename ErrorType_t, typename ProgressType_t> class AsyncValue { public: using ValueType = ValueType_t; using ErrorType = ErrorType_t; using ProgressType = ProgressType_t; // public API private: QReadWriteLock m_lock; std::variant<ValueType, ErrorType, ProgressType> m_value; }; 

Tous ceux qui rencontrent des classes qui prennent en charge le multithreading savent que l'interface de ces classes est différente des analogues à thread unique. Par exemple, la fonction size () est inutile et dangereuse dans un vecteur multi-thread. Son résultat peut devenir immédiatement invalide, puisque le vecteur peut être modifié en ce moment dans un autre thread.

Les utilisateurs de la classe AsyncValue doivent pouvoir accéder aux données de classe. L'émission d'une copie des données peut être coûteuse, tous les types ValueType / ErrorType / ProgressType peuvent être lourds. L'émission d'un lien vers des données internes est dangereuse - à tout moment, elle peut devenir invalide. La solution suivante est proposée:

1. Donnez accès aux données via les fonctions accessValue / accessError / accessProgress, dans lesquelles sont reçues les lambdas qui reçoivent les données correspondantes. Par exemple:

 template <typename Pred> bool accessValue(Pred valuePred) { QReadLocker locker(&m_lock); if (m_value.index() != 0) return false; valuePred(std::get<0>(m_value)); return true; } 

Ainsi, l'accès à la valeur interne s'effectue par référence et se trouve sous le verrou de lecture. Autrement dit, le lien au moment de l'accès ne deviendra pas invalide.

2. L'utilisateur AsyncValue dans la fonction accessValue peut se souvenir du lien vers les données internes, à condition qu'il soit abonné au signal stateChanged et qu'après traitement, le signal ne doit plus utiliser ce lien, car elle deviendra invalide.

Dans de telles conditions, le consommateur AsyncValue est toujours garanti d'avoir un accès aux données valide et pratique. Cette solution a plusieurs conséquences qui affectent l'implémentation de la classe AsyncValue.

Tout d'abord, notre classe devrait envoyer un signal lorsqu'un état change, mais en même temps c'est un modèle. Nous devrons ajouter une classe Qt de base, où nous pourrons déterminer le signal par lequel le widget mettra à jour son contenu, et tous les intéressés mettront à jour les liens vers les données internes.

 lass AsyncValueBase : public QObject { Q_OBJECT Q_DISABLE_COPY(AsyncValueBase) signals: void stateChanged(); }; 

Deuxièmement, le moment de l'envoi du signal doit être bloqué pour la lecture (afin que AsyncValue ne puisse pas être modifié jusqu'à ce que tout le monde traite le signal) et, plus important encore , à ce moment-là, il devrait y avoir des liens valides vers des données nouvelles et anciennes. Parce que dans le processus d'envoi du signal, certains consommateurs AsyncValue utilisent toujours les anciens liens et ceux qui ont traité le signal utilisent les nouveaux.

Il s'avère que std :: variant ne nous convient pas et nous devrons stocker des données dans la mémoire dynamique afin que les adresses des nouvelles et anciennes données restent inchangées.

Une petite digression.

Vous pouvez envisager d'autres implémentations de la classe AsyncValue qui ne nécessitent pas d'allocations dynamiques:

  1. Ne donnez aux consommateurs que des copies des données internes AsyncValue. Comme je l'ai écrit plus tôt, une telle solution peut être plus sous-optimale si les données sont volumineuses.
  2. Définissez deux signaux au lieu d'un: stateWillChange / stateDidChange. Obliger les consommateurs à se débarrasser des anciens liens au premier signal et à recevoir de nouveaux liens au second signal. Ce schéma, il me semble, complique excessivement les consommateurs AsyncValue, car ils ont des intervalles de temps lorsque l'accès à AsyncValue est refusé.

L'implémentation schématique suivante de la fonction setValue est obtenue:

 void AsyncValue::setValue(...) {  m_lock            {   m_lock          m_lock   }  stateChanged       m_lock   }; 

Comme vous pouvez le voir, nous devons augmenter le verrou m_lock pour l'écriture et le retourner pour la lecture. Malheureusement, il n'y a pas un tel support dans la classe QReadWriteLock. Vous pouvez obtenir la fonctionnalité souhaitée avec une paire de QMutex / QReadWriteLock. Voici une implémentation de la classe AsyncValue, proche du réel:

 //   AsyncValue enum class ASYNC_VALUE_STATE { VALUE, ERROR, PROGRESS }; Q_DECLARE_METATYPE(ASYNC_VALUE_STATE); //        class AsyncValueBase : public QObject { Q_OBJECT Q_DISABLE_COPY(AsyncValueBase) signals: void stateChanged(ASYNC_VALUE_STATE state); protected: explicit AsyncValueBase(ASYNC_VALUE_STATE state, QObject* parent = nullptr); //     PromoteToWriteLock/DemoteToReadLock QMutex m_writeLock; QReadWriteLock m_contentLock; //   ASYNC_VALUE_STATE m_state; }; template <typename ValueType_t, typename ErrorType_t, typename ProgressType_t> class AsyncValueTemplate : public AsyncValueBase { //  struct Content { std::unique_ptr<ValueType_t> value; std::unique_ptr<ErrorType_t> error; std::unique_ptr<ProgressType+t> progress; }; Content m_content; public: using ValueType = ValueType_t; using ErrorType = ErrorType_t; using ProgressType = ProgressType_t; //    template <typename... Args> void emplaceValue(Args&& ...arguments) { moveValue(std::make_unique<ValueType>(std::forward<Args>(arguments)...)); } //    void moveValue(std::unique_ptr<ValueType> value) { //       Content oldContent; //   emplaceXXX/moveXXX    QMutexLocker writeLocker(&m_writeLock); { //       QWriteLocker locker(&m_contentLock); //      oldContent = std::move(m_content); //    m_content.value = std::move(value); //    m_state = ASYNC_VALUE_STATE::VALUE; //     } //   emitStateChanged(); //    emplaceXXX/moveXXX  //    } //   value void emplaceError(Args&& ...arguments); void moveError(std::unique_ptr<ErrorType> error); void emplaceProgress(Args&& ...arguments); void moveProgress(std::unique_ptr<ProgressType> progress); template <typename Pred> bool accessValue(Pred valuePred) { //     QReadLocker locker(&m_contentLock); //    if (m_state != ASYNC_VALUE_STATE::VALUE) return false; //      valuePred(*m_content.value); //     return true; } //  accessValue bool accessError(Pred errorPred) bool accessProgress(Pred progressPred) }; 

Pour ceux qui ne sont ni fatigués ni perdus, nous continuons.

Comme vous pouvez le voir, nous avons des fonctions accessXXX qui n'attendent pas que AsyncValue entre dans l'état correspondant, mais renvoie simplement false. Il est parfois utile d'attendre de manière synchrone jusqu'à ce qu'une valeur ou une erreur apparaisse dans AsyncValue. Essentiellement, nous avons besoin d'un analogue de std :: future :: get. Voici la signature de la fonction:

 template <typename ValuePred, typename ErrorPred> void wait(ValuePred valuePred, ErrorPred errorPred); 

Pour que cette fonction fonctionne, nous avons besoin d'une variable de condition - un objet de synchronisation qui peut être attendu dans un thread et activé dans un autre. Dans la fonction d'attente, nous devons attendre, et lorsque nous changeons l'état de AsyncValue de Progress en Value ou Error, nous devons informer les serveurs.

L'ajout d'un autre champ à la classe AsyncValue, qui est nécessaire dans de rares cas lorsque la fonction d'attente est utilisée, m'a conduit à penser - ce champ peut-il être rendu facultatif? La réponse est évidente, bien sûr, elle est possible si vous stockez std :: unique_ptr et la créez si nécessaire. La deuxième question s'est posée - est-il possible de rendre ce champ facultatif et de ne pas faire d'allocations dynamiques. Peu importe, regardez le code suivant. L'idée principale est la suivante: le premier appel d'attente crée une structure QWaitCondition sur la pile et écrit son pointeur sur AsyncValue, les appels d'attente suivants vérifient simplement si le pointeur n'est pas vide, utilisez la structure par ce pointeur, si le pointeur est vide, voir ci-dessus pour le premier appel d'attente .

 class AsyncValueBase : public QObject { ... struct Waiter { //     QWaitCondition waitValue; //   wait quint16 subWaiters = 0; //  wait     QWaitCondition waitSubWaiters; }; //    Waiter* m_waiter = nullptr; }; template <typename ValuePred, typename ErrorPred> void wait(ValuePred valuePred, ErrorPred errorPred) { //   -      if (access(valuePred, errorPred)) return; //  AsyncValue   QMutexLocker writeLocker(&m_writeLock); //     if (access(valuePred, errorPred)) return; //    wait  if (!m_waiter) { //  Waiter   Waiter theWaiter; //       if SCOPE_EXIT { //     wait, //    theWaiter if (m_waiter->subWaiters > 0) { //    subWaiters   do { m_waiter->waitSubWaiters.wait(&m_writeLock); } while (m_waiter->subWaiters != 0); } //   wait  , //       Waiter m_waiter = nullptr; }; //    Waiter  AsyncValue //    wait   m_waiter = &theWaiter; //   AsyncValue     Value  Error //    do { m_waiter->waitValue.wait(&m_writeLock); } while (!access(valuePred, errorPred)); } //   wait   else { //       else SCOPE_EXIT { //      m_waiter->subWaiters -= 1; //     ->   wait if (m_waiter->subWaiters == 0) m_waiter->waitSubWaiters.wakeAll(); }; //      m_waiter->subWaiters += 1; //   AsyncValue     Value  Error //    do { m_waiter->waitValue.wait(&m_writeLock); } while (!access(valuePred, errorPred)); } } 

Comme déjà mentionné, AsyncValue n'a pas de méthode pour le calcul asynchrone afin de ne pas être lié à une bibliothèque spécifique. Au lieu de cela, des fonctions gratuites sont utilisées qui implémentent l'asynchronie d'une manière ou d'une autre. Voici un exemple de calcul de AsyncValue sur un pool de threads:

 template <typename AsyncValueType, typename Func, typename... ProgressArgs> bool asyncValueRunThreadPool(QThreadPool *pool, AsyncValueType& value, Func&& func, ProgressArgs&& ...progressArgs) { //    auto progress = std::make_unique<typename AsyncValueType::ProgressType>(std::forward<ProgressArgs>(progressArgs)...); //    auto progressPtr = progress.get(); //    AsyncValue if (!value.startProgress(std::move(progress))) return false; QtConcurrent::run(pool, [&value, progressPtr, func = std::forward<Func>(func)](){ SCOPE_EXIT { //     AsyncValue,    value.completeProgress(progressPtr); }; //  AsyncValue func(*progressPtr, value); }); return true; } 

La bibliothèque implémente deux autres fonctions similaires: asyncValueRunNetwork pour le traitement des requêtes réseau et asyncValueRunThread, qui effectue une opération sur un thread nouvellement créé. Les utilisateurs de bibliothèque peuvent facilement créer leurs propres fonctions et les utiliser pour utiliser les outils asynchrones qu'ils utilisent ailleurs.

Pour augmenter la sécurité, la classe AsyncValue a été étendue avec une autre classe de modèle AsyncTrackErrorsPolicy, qui vous permet de répondre à l'utilisation abusive d'AsyncValue. Par exemple, voici l'implémentation par défaut de la fonction AsyncTrackErrorsPolicy :: inProgressWhileDestruct, qui sera appelée si AsyncValue est détruite pendant l'exécution de l'opération asynchrone:

  void inProgressWhileDestruct() const { Q_ASSERT(false && "Destructing value while it's in progress"); } 

Quant aux widgets, leur implémentation est assez simple et concise. AsyncWidget est un conteneur qui contient un widget pour afficher une erreur, une progression ou une valeur en fonction de l'état dans lequel AsyncValue est actuellement.

  virtual QWidget* createValueWidgetImpl(ValueType& value, QWidget* parent); virtual QWidget* createErrorWidgetImpl(ErrorType& error, QWidget* parent); virtual QWidget* createProgressWidgetImpl(ProgressType& progress, QWidget* parent); 

L'utilisateur est obligé de redéfinir uniquement la première fonction, pour afficher la valeur, les deux autres ont des implémentations par défaut.

La bibliothèque qt-async s'est avérée compacte, mais en même temps très utile. L'utilisation d'AsyncValue / AsyncWidget, où il y avait auparavant des fonctions synchrones et une interface graphique statique, permettra à vos applications de devenir modernes et plus réactives.

Pour ceux qui ont lu le bonus jusqu'à la fin - une vidéo de l'application de démonstration

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


All Articles