多线程设计与撤销/重做栈

3

我有一个调用堆栈来执行繁重的计算:

// QML

StyledButton {
    onButtonClicked: {
        registeredCppClass.undoHandler.createCommand()
    }
}

void UndoHandler::createCommand()
{
    m_undoStack->push(new Command());
}

class Command : public QUndoCommand
{
public:
    Command();
    virtual ~Command();

    virtual void undo();
    virtual void redo();
    
    // ...
private:
    // Handler does the logic
    LogicHandler *m_logicHandler;
    // Output by logic handler
    QString m_outputName;
};

void Command::redo()
{
    
    if (/* */) {
        
    } else {
        // Run heavy computation
        m_outputName = m_logicHandler->run();
    }
}

QString LogicHandler::run()
{
    // Heavy computation starts
}

意图

我想要采用这个方法来实现QThread,以避免在执行大量计算时使GUI无响应。但是我不知道QThreadWorker类需要放置在哪里进行实现。它们应该位于以下哪里:

  • UndoHandler::createCommand
  • Command::redo
  • LogicHandler::run
  • ... ?

考虑到它们的信号槽连接,最佳的QThreadWorker位置是什么?


由于UndoCommand类拥有逻辑处理程序,因此在同一类中启动新线程并将逻辑处理程序放入构造函数中对我来说是有意义的。一个缺点是当命令销毁时(这可能经常发生),您必须调用线程wait(),这可能需要时间。 - Minh
我会建立一个系统级的线程池,并将任务提交给它。 - rustyx
2个回答

2
一般建议是不要阅读 QThread 文档。再加上不要阅读 Linux 线程文档。我是一个写过 相当多软件开发书籍 的人,我这样说。
长话短说,早期的线程设计并没有很好地考虑,因此存在着很多现在看来已经过时的信息。在 Qt 3.x 和我认为早期的 Qt 4.x 中,我们应该从 QThread 派生出一个类并重写 run()。对于那些不熟悉线程、无法设计互斥或其他访问保护方案以便在多个线程中操作事物的新手开发人员来说,你可以想象出这种方式的效果有多糟糕。
你的设计似乎是参考了部分这种文档。这些文档仍然在网络上流传着。
在 Qt 4.x 的某个点上,我们不再需要从 QThread 派生子类。相反,我们只需创建一个 QThread 并 moveToThread()。这种方法在某种程度上运作良好,但如果程序没有沿着代码的正常路径执行,则仍可能遇到“悬空线程”的问题。
至少在我的接触范围内,在同一时间,我们还获得了全局线程池。
你的设计真的存在缺陷,因为你看了旧文档。这不是你的错,因为搜索结果中经常会先出现旧文档。
访问这个GitHub仓库并拉取项目。我完成的唯一dev_doc设置文档是针对Fedora的。如果没有被打断,我今天上午将在Ubuntu上工作。 一定要查看diamond-themes分支。 是的,这使用了CopperSpice,但CopperSpice是Qt 4.8的一个分支,这是我能想到的唯一具体的代码示例。你可以构建和运行编辑器,或者通过阅读advfind_busy.cpp来尝试。你要找的是如何使用QFuture。那个源文件只有大约200行,并且有一个短的头文件。
放弃你目前的设计。你需要QFuture和QtConcurrent::run()。 注意:与当前的Qt 5.x相比,这些东西的头文件名称和位置不同。如果你选择继续使用Qt,你需要查找这些信息。你如何使用这些东西则不需要。
注意2:如果您没有某种限制每个任务为单个线程实例的节流控制,那么您需要动态创建和销毁QFuture对象。这意味着您必须有某种列表或向量来跟踪它们,并且您的对象析构函数需要遍历该列表以终止线程并删除对象。
如果您想在Ubuntu上设置CopperSpice,可以参考从这里开始的多部分博客文章

我认为QFutureQtConcurrent值得一试。一些注意事项:1)问题中提到的使用QThread的方法不需要子类化任何东西,这已经是正确的路径了。2)该问题仅给出了QUndoCommand部分的实现,而且我认为那部分并没有“真正的缺陷”。 - Minh
LogicHandler::run() 绝对表示一个派生自 QThread 的类。问题明确声明了“打算实现 QThread”。在当前的 Qt 世界中,“实现 QThread”是非常错误的。其中关键的部分是需要看到 LogicHandler 的实现。 - user3450148
1
这个答案可以通过删除一些无关的段落(CopperSpice?)并插入一个实际的QtConcurrent :: run()代码示例来改进。 - JarMan
答案是完整的。SO(和其他网站)上真正糟糕的趋势之一是人们发布既不能编译也不能运行的代码片段。他们需要知道如何构建和运行,以便完全测试。在各种方法中添加QDebug()消息,以便他们可以看到调用了什么以及使用了什么。获得完整的理解。假设构建环境的约5行代码片段应始终被投票否决。开发人员需要完全功能的代码。 - user3450148

2
在我看来,你的意图是正确的,而且你正在朝着正确的方向前进(暂且不考虑使用QtConcurrency——线程池和futures的争论,因为这与立即的问题无关)。让我们先来解决第一个部分:对象和执行流程。
由于类已经在代码片段中进行了概述,因此您需要特别注意正确地将它们推送到跨线程边界。如果您仔细想一下,worker对象是在调用线程中创建的,因此该对象的某些成员也将在调用线程中创建。对于指针成员,这并不构成太大的问题,因为您可以选择延迟这些对象的创建,直到封闭对象实例被创建并移动到工作线程之后。但是,嵌入式对象是在对象构造时创建的。如果嵌入式对象派生自QObject,则会将其线程亲和性设置为调用线程。在这种情况下,信号将无法正常工作。为了缓解这个问题,通常最简单的方法是将工作线程传递给worker对象的构造函数,以便worker对象能够将其所有嵌入式对象移动到工作线程。
其次,假设以下内容:
1. Command持有LogicHandler的唯一实例,且 2. LogicHandler没有状态,并且 3. LogicHandler是QObject的子类,并且 4. LogicHandler是worker类
我的建议是将线程的启动放置在Command::redo中,然后连接信号,类似于此文章底部给出的建议。此外,您不应将Command.m_outputName设置为LogicHandler::run的返回值。LogicHandler::run应该返回void。相反,您应该向LogicHandler添加一个信号,在处理完成时发出字符串值;然后,在Command中添加一个槽来处理它。QString可以轻松地跨线程边界进行编组(确保您对正确的类型进行连接,请参见此处)。
连接工作线程启动方法到线程的started信号会启动执行。无需继承QThread并覆盖run方法。工作器应该发出一个finished信号,并将其连接到线程的quit插槽。工作器的finished信号也应该连接到线程和工作器的deleteLater插槽。设置完毕后,只需调用线程的start方法即可。
从那里开始,执行将从redo返回,并且当工作器发出一个信号(我提到过需要添加的信号)并传递输出字符串时,您将收到通知工作器已完成。如果工作器的生命周期与Command实例不同(我猜是更长时间,因为您需要启动一个线程来执行长操作),则需要将工作器对象的返回值信号连接到另一个对象。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接