Coroutine在PyQt中的简单应用


类型:Python,创建时间:March 12, 2013, 5:33 p.m.

标题无“转载”即原创文章,版权所有。转载请注明来源:http://hgoldfish.com/blogs/article/85/。

在PyQt程序内操纵网络连接是一个非常常见的需求,纯粹的单机程序已经非常的少见。解决这个问题,习惯于C++的Qt开发者第一个反应可能是QtNetwork,而习惯于Python的开发者第一个反应则是Python的标准库socket, urllib2或者第三方模块requests等。老鱼在实际代码中比较两种方案,觉得后者会方便点。

QtNetwork利用Qt本身的signal/slot机制来控制数据的发送与接收。signal/slot机制本质上是回调函数。因此,它与各种异步式网络编程一样有相似的优缺点。如果只是简单地从一个http服务器取得数据,这个方案不失为方便的方案,因为Qt本身的封装使得程序员只要几行代码就能完成这种简单任务。但是当程序员们碰到Http Session、实现交互复杂的协议时,QtNetwork就相当地不方便了。一者QtNetwork功能不完善,很多东西得自己发明轮子。二者,网络异步编程对于程序员脑袋是个巨大的挑战,被誉为新时代的goto。所以,我觉得QtNetwork不是好的解决方案。

相比之下,多数程序员或许更喜欢使用requests、socket等阻塞式的编程方式。因为这种方式被多数程序员所熟悉,我就不再多讲了。它的最大缺点是必须配合多线程或者多进程,才不会阻塞PyQt的UI事件循环。受gevent, eventlet的启发,我觉得在这种情况下可以使用coroutine。

Python目前最实用的基于coroutine的网络编程模块是eventlet和gevent,在CPython里面,它们都依赖于greenlet模块。或许twisted也算,但是inlineCallbacks的写法相比greenlet稍显麻烦,不支持monkey patch也不便于利用第三方模块。因为gevent底层是libevent或者libev,不方便集成到Qt里面,所以我最终转向eventlet。通过实现一个大约一百行左右的Hub就能把eventlet集成到PyQt的事件循环里面。相关的代码在https://code.google.com/p/eventlet-pyqt/。值得高兴的是,拜eventlet的monkey patch所赐,第三种方案的代码与第二种并没有太大的差异。以下一个例子,当用户在窗口内按下鼠标中键时,程序抓取项目主页显示到文本框内。

from hgoldfish.utils import eventlet
from eventlet.green import urllib
from PyQt4.QtCore import Qt
from PyQt4.QtGui import QApplication, QTextBrowser

class TestWidget(QTextBrowser):
    def __init__(self):
        QTextBrowser.__init__(self)
        self.operations = eventlet.GreenletGroup()

    def mousePressEvent(self, event):
        if event.button() == Qt.MidButton:
            self.operations.spawn(self.getpage)
        QTextBrowser.mousePressEvent(self, event)

    def getpage(self):
        page = urllib.urlopen("http://code.google.com/p/eventlet-pyqt/").read().decode("utf-8")
        self.setHtml(page)

app = QApplication([])
w = TestWidget()
w.show()
eventlet.start_application(quitOnLastWindowClosed = True)

在这里,似乎除了用GreenletGroup.spawn()代替了Thread.start(),其它的看起来没啥区别。然而,你现在不再需要考虑锁了。这是coroutine相对于threading最大的好处。而且作为Python程序员,额外得到的好处是greenlet.kill()方法,它将简化很多的设计。

正如gevent/eventlet一样,Coroutine式的GUI编程优胜于多线程的GUI编程,免去各种同步的麻烦。麻烦主要来自于UI库本身,因为多数UI库不是线程安全的。虽然在PyQt里面,在线程内调用主线程的UI函数并不怎么麻烦,参见我的另一篇博客,但毕竟还是不舒服。如果能全部免去,何乐而不行呢。

接下来打算实现一个网络通讯客户端,它把一个非常复杂的任务提交给服务端,然后等待大约半分钟,以便服务端完成任务。在整个过程中,客户端会为用户标识出任务的状态。使用eventlet-pyqt方案,假设我们已经有一个基于coroutine实现的RPC——假定就是monkey-patched之后的标准库xmlrpclib,它的业务逻辑代码大概是这样的。

def submitTask(self):
    self.labelStatus.setText("正在提交任务")
    try:
        with eventlet.Timeout(30):
            self.server.submitTask(self.task)
    except eventlet.Timeout:
        self.labelStatus.setText("任务超时")
    except:
        self.labelStatus.setText("发生错误")
    else:
        self.labelStatus.setText("任务完成")

仔细看上面的代码可以发现,因为我们处于GUI线程内,所以可以任意调用QWidget的方法,不需要加锁。操作所包含的步骤越多,greenlet的优点就会越明显。就像上面的代码一样,一段十行的代码就有四行是需要同步的。如果扩大到上万行的项目,节约的代码将会非常的可观。

能想象得到,如果非线程安全的代码占了大部分,懒惰的程序员们就不愿意使用基于线程的方案,即使它从逻辑结构上来讲清晰很多。再如下面这个例子在循环处理任务时,同时更新任务的状态。

def processTasks():
    for task in self.allTasks:
        success = self.server.submitTask(task) #阻塞当前greenlet
        if success:
            self.taskModel.markFinished() #UI操作,非线程安全
        else:
            self.taskModel.markFailed()   #UI操作,非线程安全
    self.submitButton.setEnabled(True)    #UI操作,非线程安全

def on_submitButton_clicked(self):
    self.opeations.spawn(self.processTasks)
    self.submitButton.setEnabled(False)

好了,针对基于threading的同步编程没啥好说的,因为eventlet和gevent都有广泛的使用者了。反而在古老的UI编程领域,从Windows时代开始事件循环深入人心, coroutine式的UI编程较少有人提及。我觉得,有些由多个独立步骤组成的长操作,正是coroutine的适用之处。比如在文档内搜索、延迟加载数据等等。

首先第一个例子是加载文件树内的所有图片文件到图片浏览器里面,如果文件很多的话一次性加载相当地不划算。比较好的做法是根据用户当前的浏览位置决定是否加载下一个文件。应用coroutine的代码是:

def scanTree(self, path):
    i = 0
    for root, dirnames, filenames in os.walk(path):
        for filename in filenames:
            filepath = os.path.join(root, filename)
            if isImageFile(filepath):
                #根据用户的当前浏览位置决定是否加载图片
                self.dependsOnCurrentPosition.wait(i)
                self.loadImage(filepath)
                i += 1

class DependsOnCurrentPosition:
    def __init__(self):
        self.gate = eventlet.Event()
        self.currentPosition = 0

    def wait(self, i):
        #用户浏览到最后五个文件时就加载新图片
        while i > self.currentPosition + 5:
            try:
                self.gate.wait()
            finally:
                self.gate.reset()

    def setCurrentPosition(self, pos):
        self.currentPosition = pos
        self.gate.send(None)

可以思考一下,通过异步编程,这样的需求要怎么实现呢?能否比以上代码更简洁。

我觉得凡是涉及到网络的异步编程时,基本可以肯定coroutine式的UI编程会比异步编程好用。比如接下来这个处理UDP数据(或者短信等无连接的数据报文)时的一个例子。要求把消息发送给用户,然后等待用户的回应。如果超时,重新发送消息。

def sendMessage(self, remoteAddress, message):
    while True:
        self.sendUdp(remoteAddress, message)
        try:
            answer = self.waitForUdp(remoteAddress, self.timeout)
        except eventlet.Timeout:
            continue
        else:
            return answer

这个非常简单的例子淋漓尽致地体现了同步编程相对于异步编程的优势。简洁流畅,清晰易懂。这里的waitForUDP()使用了eventlet.Event()类型的特殊能力,能够把回调函数转换成同步编程模式。代码大概如下:

def waitForUdp(self, remoteAddress, timeout):
    waiter = eventlet.Event()
    self.waiters[remoteAddress] = waiter
    try:
        with eventlet.Timeout(timeout):
            return waiter.wait()
    finally:
        del self.waiters[remoteAddress]

def handleUdp(self):
    while True:
        datagram, addr = self.serverSocket.recvfrom(1024 * 8)
        self.waiters[addr].send(datagram)

类似的waitForXXX()的编程模式能够代替signal/slot,等待用户输入事件、动画播放完成事件等等。相当实用。通过这个编程模式,Coroutine式的UI编程就能用经典的方式来维护状态。接下来的代码在QGraphicsView上面实现一个两阶段的表单。每阶段的表单进入时都有机械组装那样的动画效果,页面的四个部分会依次从四个方向进入预定位置。

def showPage1():
    animation = create_animation(block1, "left-to-right")
    waitForFinished(animation)
    animation = create_animation(block2, "right-to-left")
    waitForFinished(animation)
    animation = create_animation(block3, "top-to-bottom")
    waitForFinished(animation)
    animation = create_animation(block4, "bottom-to-top")
    waitForFinished(animation)
    while True:
        action = waitForUserAction()
        if action == "next":
            if not checkBlock1():
                QMessageBox.information(...)
                continue
            if not checkBlock2():
                ...
            break
    showPage2()

与基于signal/slot的QWizard相比,这种写法看起来会不会更接近大学一年级所学习的“TC”呢?

相对于异步编程,资源管理这个隐藏的好处也值得一提。不知道PyQt程序员们有没有注意,对于Python的异步编程,因为回调函数或者signal/slot可能会引发意外的引用,所以长时间运行下来内存不小心就会不断增长。而在基于Greenlet的程序中,对象的生存周期非常直观,不容易发生错误。再配合Python的try/finallywith关键字,资源管理会更高效。在这里就不再详述了。

前面说到,Python程序员往往还会很高兴得到greenlet.kill()的能力。由于Python实现本身的缺陷,想要中止线程的运行必须设置一个标志变量——详情见我的另一篇博客。现在不会这么麻烦了。

好吧,由一段文件传输的代码来总结greenlet方案的各种优势。这里实现一个小的文件接收程序,当它接收到对方的文件传送请求时,首先询问用户是否接收此文件。用户确认并选择保存位置以后开始接收。文件传输可以在任意时间被中断,并且支持暂停、计算接收进度。简要代码如下:

def receiveFile(self, connection):
    self.setOperations("accept", "reject")
    action = self.waitForAction()
    if action == "rejct":
        self.setOperations(withMessage = "file rejected.")
        return
    targetFilename = callMethodInEventLoop(QFileDialog.getSaveFileName, ...)
    self.setOperations("pause", "cancel")
    t = self.operations.spawn(self.pauseAndResume, eventlet.getcurrent())
    try:
        with io.open(targetFilename, "wb") as target: #明确的资源管理
            received = 0
            total = self.parseHeader(connection).total
            while True:
                #当self.gate.close()被调用时,wait()阻塞,以此来实现暂停的功能。
                #类似的还有流控功能。
                self.gate.wait()  
                buf = connection.recv(1024)
                if not buf:
                    break
                target.write(buf)
                received += len(buf)
                self.setProgress(received, total)
    except IOError: #更加明确的异常控制
        self.setOperations(withMessage = "can not write to file. ")
    except socket.error:
        self.setOperations(withMessage = "connection aborted."
    except eventlet.SystemExceptions:
        self.setOpreations(withMessage = "canceled.")
    finally: #附属的Greenlet生存周期非常明确。
        t.kill()

def pauseAndResume(self, greenlet):
    while True:
        action = self.waitForAction()
        if action == "resume":
            self.setOperations("pause", "cancel")
            self.gate.close()
        elif action == "pause":
            self.setOperations("resume", "cancel")
            self.gate.open()
        else:
            greenlet.kill()

虽然greenlet看起来很好用的样子。不过,凡事不是十全十美的。greenlet在实现上有一些问题,所以在编码的时候要小心。

  1. 在greenlet里面不能抛出未被处理的异常。否则Python会整个崩溃掉。应用GreenletGroup.spawn()启动的greenlet不会有这个问题。
  2. greenlet有可能引起循环引用。在greenlet内运行的函数不能引用greenlet的拥有者。这个问题通过使用GreenletGroup可以解决,因为GreenletGroup利用weakref.proxy避免循环引用。
  3. 在转移到另外一个greenlet之前先调用sys.exc_clear()清理异常。
  4. Qt的事件循环只能在主事件循环所在的greenlet内运行。eventlet-pyqt的runLocalLoop()函数能够处理这个错误。因为Qt的对话框运行自己的本地事件循环,所以对话框类型如QMessageBox, QFileDialog同样适用这个例外。

一小段示例代码:

t = self.operations.spawn(self.showProgress)
try:
    self.server.submitTask(self.task)
except socket.error:
    eventlet.exc_clear()
    callMethodInEventLoop(QMessageBox.information, self, u"提交任务时出错", u"网络错误。")
finally:
    t.kill()

另一个值得一说的是,由于Qt本身的限制,eventlet-pyqt的网络传输速度较慢,消耗的CPU比基于threading的方案高一些。不管怎么样,我自己觉得eventlet-pyqt是个不错的尝试。我推荐您也也尝一下它的味道。有任何BUG,请关注项目主页:https://code.google.com/p/eventlet-pyqt/

标题无“转载”即原创文章,版权所有。转载请注明来源:http://hgoldfish.com/blogs/article/85/。


老房威廉(Feb. 2, 2014, 9:46 p.m.)

看上去很牛X,先试用一下。谢谢

老房威廉(Feb. 8, 2014, 11:04 p.m.)

如果使用 pyinstaller打包以后再运行,就有警告和错误。例如: C:\Users\apple\AppData\Local\Temp_MEI25802\urllib.py:1102: RuntimeWarning: Pare nt module 'urllib' not found while handling absolute import finished QObject::startTimer: QTimer can only be used with threads started with QThread

老房威廉(Feb. 10, 2014, 8:50 a.m.)

上面的例子通过pyinstaller打包以后,虽然报错,仍然可用。我把这个技术用于一个稍复杂的小工具软件, 发现打包以后运行不了。错误信息如下: File "D:\temp\build\ioaToDoReminder\out00-PYZ.pyz\hgoldfish.utils.eventlet", line 79, in hgoldfish\utils\eventlet.py的79行内容。 from eventlet import spawn, sleep, spawn_after, kill, Timeout, with_timeout, GreenPool, \

老房威廉(Feb. 10, 2014, 4:01 p.m.)

pyinstaller打包的问题应该是pyinstaller的问题,因为不打包运行没有问题的。 我没有能力改pyinstaller,只能改其他代码。先是修改hgoldfish的代码,使得 可以找到eventlet的class,然后发现greenlet无法load,原来是egg文件pyinstaller 识别不了,只好把蛋抽出来。然后是eventlet中某个py文件找不到urllib2,只好 手工修改代码把这个模块加入。现在总算好了,不知道什么时候还会有问题。 python,想说爱你不容易。

老房威廉(Feb. 10, 2014, 5:49 p.m.)

前面2个问题,当我使用exe方式安装的greenlet来代替easy_install安装的greenlet以后,居然自动消失了。对于第3个问题,主要是因为urllib2是动态load的,pyinstaller无法识别。所以要手动指定。因为eventlet是动态load一些module,所以这个繁琐工作避免不了了。

老鱼(March 14, 2014, 11:15 a.m.)

  1. 这个东西不能用在非Qt主线程之外。
  2. pyinstaller这类软件打包的软件确实有各种问题。

老鱼(Feb. 17, 2018, 10:36 a.m.)

最近我又开发了 C++/Qt 的 Coroutine 库,QtNetworkNg,大家有兴趣的话可以看看。


何不来发表一下您对本文的看法(使用Markdown语法,分段空两行):