使用 Qt 4.x 进行异步数据库访问

作者:Dave Berton

Qt 4.x 中的数据库支持非常强大。该库包含了 Oracle、PostgreSQL、SQLite 和许多其他关系数据库的驱动程序。开箱即用,Qt 数据库库还包含了许多小部件的绑定,并为透明处理来自数据库的结果集提供了数据类型。但是,您的应用程序可能会为这些便利付出代价。默认情况下,所有数据库访问都是同步的,这意味着除非采取预防措施,否则密集且耗时的 SQL 查询通常会锁定 UI。在服务器上使用存储过程有时可以帮助改善这种情况;但是,这并非总是可能或理想的。而且,通常情况下,您的应用程序生成的查询的长度和成本根本无法提前知道,因此为不良的 UI 行为敞开了大门。人们不希望他们的应用程序在奇怪的时刻“锁定”;然而,这是默认行为,因此我们必须与之抗争。

幸运的是,Qt 4.x 也对多线程编程提供了强大的支持。通过将繁重的数据库工作放在单独的线程中,UI 可以自由地正常响应用户,而不会出现不优雅的打断。然而,与所有并发编程一样,您必须采取预防措施,以确保线程之间交互的正确顺序。例如,在线程之间共享数据时,请使用互斥锁正确地保护它。在线程之间通信时,请仔细考虑交互将如何表现,以及以什么顺序进行。此外,当在与 UI 线程分离的线程中使用数据库连接时,您必须注意一些额外的注意事项。一个适当的实现,将某些事情牢记在心,将显著改善数据库应用程序的 UI 行为和响应性。

线程策略

有几种方法可以将数据库负载分配到单独的执行线程中。幸运的是,当涉及到正确创建和使用数据库连接的细节时,所有这些方法都具有相同的特征。首要考虑因素是仅在创建数据库连接的线程中使用该连接。对于常规同步应用程序,默认行为是可以的。QSqlDatabase::addDatabase() 静态函数在应用程序主 UI 线程的上下文中创建一个数据库连接。然后,在此同一线程中执行的查询将导致阻塞行为。这是可以预期的。

为了与主 UI 线程并行运行查询,以便它们不会中断主事件处理循环,必须在查询执行的线程中建立数据库连接,该线程应与主 UI 线程分离。无论您如何在应用程序中构建线程,您的设计都必须能够在每个将执行数据库工作的线程的上下文中建立连接。

例如,创建一个线程池,其中几个线程以循环方式处理查询数据库的负载(而没有一直创建和销毁线程的开销),这将把耗时的工作推到主事件循环之外。或者,根据您应用程序的需求,您可以简单地按需生成线程来执行数据库工作。在任何一种情况下,您都必须为每个线程创建一个连接。

还有一个进一步的限制(由 Qt 使用的大多数底层数据库特定库施加)。作为一般规则,连接不能由多个线程共享。这意味着您不能简单地在启动时创建一个连接池,并在需要时将它们分发给各个线程。相反,每个线程必须在其自身的上下文中建立和维护其自身的连接。否则是未定义的,并且可能是灾难性的。可以使用 QSqlDatabase::addDatabase() 静态函数的名称参数在每个线程中建立多个单独的连接,如列表 1 所示。

列表 1. 创建 QThread 的两个实例,一个用于查询,另一个用于更新。

class QueryThread : public QThread
{
  public:
    QueryThread( QObject* parent = 0 ) 
    {
      //...
    }
    void run() 
    {
        QSqlDatabase db = QSqlDatabase::addDatabase( 
        ↪"QPSQL", "querythread" );
        // use 'db' here
    }
};
  
class UpdateThread : public QThread
{
  public:
    UpdateThread( QObject* parent = 0 ) 
    {
      //...
    }
    void run() 
    {
        QSqlDatabase db = QSqlDatabase::addDatabase( 
        ↪"QPSQL", "updatethread" );
        // use 'db' here
    }
};

在列表 1 中,线程对象建立了两个不同的数据库连接。每个连接都单独命名,以便 QSqlDatabase 可以在其内部列表中正确维护它们。而且,最重要的是,每个连接都是在每个对象的单独执行线程中建立的——run() 方法由 QThread::start() 在新的执行线程启动后调用。QSqlDatabase 提供的创建新连接的机制是线程安全的。列表 2 显示了另一种更通用的方法的示例。

列表 2. 使用 QThread 的单个实例两次的更通用方法

class QueryThread : public QThread 
{ 
  public: 
    QueryThread( const QString& name ) 
      : m_connectionname(name) 
    {
      //...
    } 
    void run() 
    { 
      QSqlDatabase db =QSqlDatabase::addDatabase( "QPSQL", m_connectioname ); 
      //...
      db.open(); 
      forever 
      { 
        // wait for work 
        // and then execute it...
      }
    }
}

void main()
{
  QApplication app(...);
  MainWin mw;
  QueryThread db1("queries");
  db1.start();
  QueryThread db2("updates")
  db2.start();
  //...
  mw.show();
  app.exec();
} 

列表 2 中的伪代码创建并启动了两个工作线程。每个线程都建立一个命名连接到数据库,并等待工作执行。每个线程将始终只处理其自身的数据库连接,该连接永远不会在工作线程对象之外共享或可见。

设置线程特定的连接并在该线程中运行查询只是问题的第一部分。需要决定如何在工作线程和主应用程序 UI 线程之间来回传送数据。其他方法将为线程对象提供一些数据库工作来执行,并且这些方法本身也需要是线程安全的。

在线程之间移动数据

您应该遵守围绕不同执行线程之间数据共享的所有常用注意事项,包括正确使用互斥锁和等待条件。此外,还有一个关于数据大小的额外复杂性,在从数据库返回的结果集的情况下,数据大小可能非常大。

Qt 提供了几种专门的机制用于在线程之间发送数据。您可以使用线程安全函数 QCoreApplication::postEvent() 手动将事件发布到任何线程中的任何对象。事件将由目标对象创建的线程的事件循环自动分派。要在每个线程中创建事件循环,请使用 QThread::exec()。使用此方法,以事件的形式为线程提供“工作”来完成。QCoreApplication 以线程安全的方式将这些事件从应用程序线程传递到工作线程,并且它们将由工作线程在其自身的执行上下文中处理。这至关重要,因为工作线程将仅在其自身的上下文中利用其数据库连接(列表 3)。

列表 3. 通过事件从工作线程向应用程序线程共享信息的代码

class WorkEvent : public QEvent
{
  public:
    enum { Type = User + 1 }
    WorkEvent( const QString& query )
      : QEvent(Type) 
      , m_query(query)
    {}  
  
    QString query() const 
    { 
      return m_query; 
    }
  private:
    QString m_query;
}; 

QueryThread thread;
thread.start();
//...
WorkEvent* e = new WorkEvent("select salary from employee 
 ↪where name='magdalena';");
  app.postEvent( &thread, e );
  //...

此方法也以相反的方向工作。由各个工作线程发布的事件将显示回主 UI 事件循环中进行处理,在 UI 线程的执行上下文中。例如,这些事件可以包含查询或数据库更新的结果。这种方法很方便,因为工作线程可以简单地“发布并忘记它”,而 Qt 将负责线程间通信、互斥和内存管理。

为此类系统构建所有必要的事件和事件处理程序具有优势——最值得注意的是编译时类型检查。但是,正确地设计和处理所有事件可能是一项繁重的任务,特别是如果您的应用程序有多种类型的数据库查询要执行,具有多种返回类型,每种类型都需要关联的事件和事件处理程序等等。

幸运的是,Qt 允许跨线程连接信号和槽——只要线程正在运行它们自己的事件循环。与发送和接收事件相比,这是一种更简洁的通信方法,因为它避免了在任何重要的应用程序中都变得必要的所有的簿记和中间 QEvent 派生类。现在,线程之间的通信变成了将一个线程的信号连接到另一个线程的槽的问题,并且线程之间交换数据的互斥和线程安全问题由 Qt 处理。

为什么有必要在每个要连接信号的线程中运行事件循环?原因与 Qt 在将一个线程的信号连接到另一个线程的槽时使用的线程间通信机制有关。当建立这样的连接时,它被称为排队连接。当通过排队连接发出信号时,槽将在下次执行目标对象的事件循环时被调用。如果槽是通过来自另一个线程的信号直接调用的,则该槽将在与调用线程相同的上下文中执行。通常,这不是您想要的(尤其是在您使用数据库连接时,因为数据库连接只能由创建它的线程使用)。排队连接正确地将信号分派到线程对象,并通过搭载在事件系统上来在其自身的上下文中调用其槽。这正是我们想要的线程间通信,其中一些线程正在处理数据库连接。Qt 信号/槽机制从根本上来说是上面概述的线程间事件传递方案的实现,但具有更简洁且更易于使用的界面。

例如,您可以在主 UI 对象和工作线程对象之间创建两个简单的连接;一个用于向工作线程添加查询,另一个用于报告结果。这个简单的设置,仅需几行代码,就建立了异步数据库应用程序的主要通信机制(列表 4)。

列表 4. 使用信号和槽跨线程共享信息更简洁。

class Worker; // forward decl
class QueryThread : public QThread
{
  QueryThread();
  signals:
    void queryFinished( const QList<QSqlRecord>& records );
  slots:
    void slotExecQuery( const QString& query );
  signals:
    void queue( const QString& query );
  private:
    Worker* m_worker;
};

int main()
{
  QApplication app;
  
  MainWin mw;
  
  QueryThread t;
  t.start();
  
  connect(&mw, SIGNAL( execQuery(const QString&)),
          &t, SLOT( slotExecQuery(const QString&)));
  
  connect(&t, SIGNAL( queryFinished(const QList<QSqlRecord>&)),
          &mw, SLOT( slotDisplayResults(const QList<QSqlRecord>&)));

  mw.show();
  return app.exec();
}

在这里,MainWin 和 QueryThread 对象以通常的方式通过信号和槽相互直接通信。这里的诀窍是 QueryThread 对象在幕后利用 Worker 对象来执行所有工作。为什么需要这个额外的间接级别?因为我们想将工作分派给与完全独立的执行线程关联的对象。请注意,上面 QueryThread 是在主执行线程中实例化和 start() 的;因此,QueryThread 将“属于”应用程序的主线程。将来自应用程序小部件的信号连接到其槽将通过“直接”连接——它们将由 Qt 立即调用,以通常的方式,因此是阻塞的、同步的函数调用。相反,我们对“推送”信号到完全在单独的执行线程中运行的槽感兴趣,这就是内部 Worker 类发挥作用的地方(列表 5)。

列表 5. 额外的间接层使执行更异步。

class Worker : public QObject
{
  Q_OBJECT

   public:
    Worker( QObject* parent = 0);
    ~Worker();

  public slots:
    void slotExecute( const QString& query );
 
  signals:
    void results( const QList<QSqlRecord>& records );

   private:
     QSqlDatabase m_database;
};

void QueryThread::run()
{
  // Create worker object within the context of the new thread
  m_worker = new Worker();

  // forward to the worker: a 'queued connection'!
  connect( this, SIGNAL( queue( const QString& ) ),
           m_worker, SLOT( slotExecute( const QString& ) ) );
  // forward a signal back out
  connect( m_worker, SIGNAL( results( const QList<QSqlRecord>& ) ),
           this, SIGNAL( queryFinished( const QList<QSqlRecord>& ) ) );

  exec();  // start our own event loop
}

void QueryThread::execute( const QString& query )
{
  emit queue( query ); // queues to worker
}

通过在内部利用这个 Worker 类,QueryThread 可以通过使用 Qt 提供的方便的线程间信号/槽排队连接,将 SQL 查询正确地分派到单独的线程。QueryThread 封装了线程的概念,通过从 QThread 派生并能够 start() 新的执行线程,并且它还封装了工作者的概念(通过公开执行数据库工作的便捷方法,这些方法在内部被分派到单独的执行线程)。因此,无需纠缠所有必要的事件和事件处理程序来完成相同的任务,Qt 元对象系统为您设置了所有必要的代码,并公开了 connect() 函数来触发所有这些。信号可以随时发出,并将正确地分派到目标对象,在该对象的上下文中。

重要的是要注意,在上面,QueryThread 的 execute() 方法旨在由主应用程序同步调用。QueryThread 提供此方法不仅是为了方便;实际上,它还封装并隐藏了 QueryThread 类的用户的所有排队连接细节。当调用 execute() 时,QueryThread 只是将其作为信号发送给工作者。因为工作者是一个“存在”于单独线程中的对象,所以 Qt 将“排队”连接并通过事件循环传递它,以便它到达工作者的正确执行上下文中——这对于工作者根据本文开头提出的规则利用其数据库连接至关重要。最后,来自工作者的任何信号都通过 QueryThread 接口“转发”回去——这对于 QueryThread 的用户来说是另一个便利,它也服务于向那些实际上不需要首先了解 Worker 类的人隐藏 Worker 类的所有细节。

如果您的查询大小巨大,或者可能巨大,您应该考虑一种不同的策略来处理结果集。例如,在将结果传递回 UI 线程之前,将结果写入磁盘将减少应用程序的内存负载,但会牺牲一些运行时速度。但是,如果您已经在处理大量查询,那么与查询的整体执行时间相比,速度损失可能很小。因为所有数据库查询都在与 UI 完全分离的线程中执行,所以用户不会感觉到任何明显的延迟。将中间结果存储在文本文件中,或者为了最大的灵活性,存储在本地 SQLite 数据库中。

这个难题的一个要素仍然需要解决。排队连接机制依赖于线程的事件循环来在其线程的上下文中正确调用槽,如前所述。但是,为了编组将事件分派到另一个执行线程所需的数据,必须使 Qt 对象系统知道信号/槽声明中使用的任何自定义数据类型。未能注册自定义数据类型将导致排队连接失败,因为为了分派事件,必须制作每个信号参数的副本。幸运的是,只要这些类型具有公共构造函数、复制构造函数和析构函数,那么“注册”其他类型就很简单了(列表 6)。

列表 6. 您必须注册任何自定义数据类型才能共享它们。

// first, make the object system aware
qRegisterMetaType< QList<QSqlRecord> >("QList<QSqlRecord>");
// now set up the queued connection 
connect( m_worker, SIGNAL( results( const QList<QSqlRecord>& ) ),
         this, SIGNAL( queryFinished( const QList<QSqlRecord>& ) ) );

本文提供的示例应用程序实现了上述策略,其中查询与应用程序的其余部分并行执行[该应用程序可从 Linux Journal FTP 站点下载,ftp.linuxjournal.com/pub/lj/listings/issue158/9602.tgz]。当查询正在进行时,UI 不会被打扰。在将适当的类型注册到 Qt 元对象系统后,在 QueryThread 接口和封装的工作线程之间创建排队连接。这允许单独的线程以最小的开销和代码复杂性安全地相互通信。示例应用程序已使用 SQLite 和 PostgreSQL 进行测试;但是,它将与 Qt 支持的任何强制执行相同连接-每线程限制的数据库连接一起工作。

总结

在 Qt 中设计异步数据库应用程序时,应牢记以下几点

  • 为每个线程创建一个数据库连接。使用线程安全的 QSqlDatabase::addDatabase() 方法的 name 参数,以便区分各种数据库连接。

  • 尽可能将数据库连接封装在工作线程对象中。永远不要与其他线程共享数据库连接。永远不要从创建数据库连接的线程以外的任何线程使用数据库连接。

  • 使用 Qt 提供的工具管理线程之间的通信。除了 QMutex、QSemaphore 和 QWaitCondition 之外,Qt 还提供了更直接的机制:事件和信号/槽。跨线程边界的信号/槽的实现依赖于事件;因此,请确保您的线程使用 QThread::exec() 启动它们自己的事件循环。

  • 向 Qt 元对象系统注册未知类型。如果不首先调用 qRegisterMetaType(),则任何未知类型都无法正确编组。这使得排队连接能够使用新类型在单独线程的上下文中调用槽。

  • 利用排队连接在应用程序和数据库线程之间进行通信。排队连接为处理异步数据库连接提供了所有优势,但使用 QObject::connect() 保持了简单而熟悉的界面。

Dave Berton 是一位在 Eventide, Inc. 工作的专业程序员。欢迎您发送评论至 dberton@eventide.com

加载 Disqus 评论