异步Python与数据库[译]

<small>原文作者简介:</small>
<small>Mike Bayer 是多个Python开源库的作者,包括 SQLAlchemy, Alembic Migrations, Mako 模板, 和 Dogpile Caching
</small>
<small>此翻译得到了作者的许可,查看原文</small>

异步编程这个话题很难讲。 因为这不是一个单独的东西,而且我又基本上在这方面是一个门外汉。 但是,因为我在处理很多关系数据库和Python相关工作时必须和他们打交道,所以我必须针对异步IO和数据库编程提出许多问题,特别是关于SQLAlchemy以及OpenStack的。

因为对于这个话题,我没法简单的给出一个意见,所以我先在这里对文章的剩余部分做个剧透。我认为Python的asyncio库,实现优雅,前途光明,富有乐趣,组织良好,很显然SQLAlchemy对它一定程序上的兼容是可行的,甚至是兼容这个ORM的大部分模块。

虽然这么说,我还是认为异步编程可能只是一个该被束之高阁的东西,而不是一个我们应该一直使用,或者大部分时间使用的技术,除非我们正在编写一个特别需要同时维护大量任意慢速或空闲的TCP连接的HTTP服务器或者聊天服务(我们所说的“任意”,是说我们并不在乎个别连接是快是慢,或者空闲,只要吞吐量可以保持不变)。 对于标准商业风格的,增删改查数据库的代码,asyncio给出的方法从来就不是必须的,甚至几乎可以肯定会拖累性能,而且它对关系数据库编程方面提升的论据的“正确性”是非常可疑的。 需要在前端执行非阻塞IO的应用程序应该将业务级增删改查代码留在线程池之后。

文章就这样随着我这个没有惊喜的视角开始吧!

什么是异步IO?

异步IO是一种实现并发的方法,它通过允许在等待IO操作响应时继续程序处理实现。为了达到这个目的,IO函数调用是非阻塞的,以便在实际的IO操作完成甚至开始之前立即返回。 一个典型的依赖于操作系统的轮询系统(如epoll)会循环的在一组文件描述符中查询下一个具有可用数据的文件描述符;当定位到它时,它会被处理,当处理完成时,控制返回到轮询循环,以便对下一个具有可用数据的描述符进行操作。

非阻塞IO的一个经典用例,针对使用专用线程等待套接字效率不高的情况。当你需要监听大量“犯困”或者缓慢的套接字时,这是个很有必要的技术,最好的例子是聊天服务器,或者类似的消息系统,你有很多持久的连接,而发送数据只是偶发的;例如,当一个连接实际发送数据时,我们把它当做 是一个要响应的事件。

近年来,异步IO方法 被成功的应用到HTTP相关的服务器和程序上。它的操作理论是,高效地服务大量的HTTP连接,而不需要服务器用专用线程来单独等待每个连接; 特别是缓慢的HTTP客户端不需要妨碍服务器同时为大量其他客户端服务。 将这一点与所谓的长轮询方法的重新流行结合起来,像nginx这样的非阻塞网络服务器已经被证明工作得很好。

异步IO和脚本语言编程

脚本语言中的异步IO编程主要集中在事件循环的概念上,在最典型的形式中,它使用回调函数,一旦相应的IO请求具有可用数据,就会接收调用。 这种类型的编程的一个关键方面是,由于事件循环具有为等待IO的一系列函数提供调度的效果,所以脚本语言尤其可以完全取代线程和OS级调度的需要,至少在一个CPU内。 实际上,将多线程的阻塞IO代码与使用非阻塞IO的代码集成在一起可能有些尴尬,因为在调用面向IO的方法时,它们必然会使用不同的编程方法。

由于异步IO与事件循环之间的关系,再加上它在面向Web服务器的应用程序中越来越流行,以及以直观和明显的方式提供并发的能力,种种的因素让它在某个特定的平台上变得很受欢迎,这就是Javascript。Javascript被设计成用于浏览器的客户端脚本语言。像其他GUI应用程序一样,浏览器基本上是基于事件的; 它们的工作只是响应用户发起的按钮按压,按键按下和鼠标移动的事件。因此,Javascript有一个非常强大的回调事件循环,直到最近,它也根本没有任何多线程编程的概念。

从上世纪90年代到2000年,一批前端掌握了这些客户端回调的开发人员,开始将它们不仅用于用户发起的事件,而且还用于通过AJAX连接用于网络发起的事件,这将会把日益增长的JavaScript程序员社区带到一个新的舞台。

服务器

Node.js不是第一次把Javascript变成服务器端语言的尝试。然而,它能成功的一个关键原因是,在发布的时候,已经有有大量的经验丰富的Javascript程序员了,并且它还包含了完整的事件驱动的编程范例,这些都是客户端JavaScript程序员已经熟练掌握和轻松使用的了。

为了推广这个,需要根据需要建立“非阻塞IO”方法,而不仅仅是“经常睡着或任意慢速连接”的经典案例,而是作为事实上的风格所有面向Web的软件都应该这么写。这意味着现在任何类型的网络IO都必须以非阻塞的方式进行交互,这当然包括数据库连接。然而一般的做法是,进程和数据库连接很少关联,通常使用10-50个连接维护一个连接池,这样因为TCP连接启动带来的延迟就不会成为问题了。对于那些架构合理的数据库来说,它们通常位于防火墙后面,集群通过本地网络连接,响应速度非常快,而且可控。不管怎么说,现在已经有和上述用例 完全相反、用于非阻塞IO的打算了。Postgresql数据库在libpq中提供了一个异步命令API,这里有一个例子阐述了它的基本原理 - 在GUI应用程序中使用它

Node.js已经从一个 极其高性能的支持JIT的引擎中受益,所以很有可能,尽管在非阻塞IO的情况下重新调整了非阻塞IO,但是使用非阻塞IO的数据库连接之间的调度工作可以接受。(作者注意:这里关于libuv的线程池的评论被删除,因为这只考虑文件IO。)

对线程的忧虑

在Node.js把大量客户端JavaScript开发者转变成异步服务端程序员之前,理论家们就开始抱怨多线程编程模型产生了不确定的程序。而异步编程,事件驱动模型有效地提供了另一种编程并发模型(至少对那些IO密集的程序来说它的上下文切换足够快),迅速成为用于击败多线程编程的几种方案之一。对线程的 批判主要有两个,第一,在应用程序中创建和维护线程成本过于高昂,不适合需要同时维持成百上千和连接的程序。第二,多线程编程是困难且不确定的。在Python世界中,相比其他场景,艰难晦涩的GIL,为异步模型的生长提供了肥沃的土壤。

你喜欢意大利面吗?

Node.js和其他异步模型的回调风格被认为是有问题的;为了实现复杂逻辑和大规模操作而组织的回调,使代码变得冗长而且难以追踪,这通常被称为回调意大利面(callback spaghetti)。回调究竟是意大利面条还是美好的事物,是2000年的一个重要争论,但幸运的是,我们并不需要深入了解这一点,因为异步社区已经清楚地承认了前者,并采取了很多良好的措施来改进这种情况。

在Python世界中,为了允许异步IO而不使用回调,实现了一种由eventletgevent提供的“隐式异步IO”方法。这些方法将检测IO函数,并隐式的转换成非阻塞方法,使得可以在绿色线程中并发运行,使用诸如libev之类的基于绿色线程的本地事件库来完成非阻塞IO的调度工作。使用隐式异步IO,大部分IO操作的代码根本不需要修改;在大多数情况下,相同的代码不经修改的直接用在阻塞和非阻塞IO上下文中(当然是在典型的现实世界的用例中,而不是在偶发的奇诡场景中)。

与隐式异步IO相反,Python本身提供了一个非常有前途的asyncio库,上文也曾经提到了,现在已经在Python3中可用了。Asyncio为Python带来了完全标准化的“futures”和协程的概念,我们可以像编写传统的阻塞IO代码一样编写非阻塞IO代码,同时维护了非阻塞操作的显式性质。

SQLAlchemy?Asyncio? 真的吗?

现在asyncio是Python的一部分,它是所有异步事件的通用集成点。 因为它提供了有意义的返回值和异常捕捉语义的概念,所以实现一个可用的asyncio版本的SQLAlchemy是可行的; 它仍然至少需要几个外部模块来重新实现SQLAlchemy核心和ORM的异步结果的关键方法,但似乎大多数代码,即使在以执行为中心的部分,也可以保持大致相同。它意味着不再需要重写SQLAlchemy所有部分,异步的实现应该完全不在核心库之内。我已经开始玩这个了。这里面有很大工作量,但应该是可行的,即使对于ORM的一些模式,像“懒加载”也是可以的,只是需要以一种更繁复的方式实现。

然而。我不知道你是否真的会想使用启用异步的SQLAlchemy。

让Web异步化

如预期的那样,让我们看看哪里出问题了,特别是与数据库相关的代码。

问题一 把异步当成性能魔法

在Node.js社区以及Python社区中的许多人(但当然不是全部)坚持声称异步编程风格在几乎所有情况下的并发性能都天生优越。特别是有一种观点认为,asyncio等显式异步系统的上下文切换方法实际上可以“免费”使用,并且由于Python具有GIL,所以这些方法都会肯定会比任何使用线程的方法更快,或者至少不会更慢。所以,任何Web应用程序都应该尽可能快地,彻头彻头彻尾的,从HTTP请求到数据库调用都转换为使用异步方法,并且不费任何代价就能增强性能。

我只想指出在数据库访问方面的问题。对于HTTP请求,“聊天”服务器风格的通信,无论是作为服务器侦听还是进行客户端调用,asyncio都可能非常优越,因为它可以允许更多休眠/慢速的连接,而已易于处理。但是对于本地数据库访问,情况并非如此。

1. 与您的数据库相比,Python非常慢

更新 - redditor Riddlerforce 在这一节发现了可证实的问题,因为我没有通过网络连接进行测试。此处的结果已更新。不过结论是一样的,只是不像以前那样那么夸张。

我们先来回顾一下异步编程的 甜蜜点,即I/O密集应用程序:

I/O密集应用程序是指完成计算所花费的时间主要由等待输入/输出操作完成的时间决定的。这种情况发生在数据被请求的速率比它消耗的速率慢时,或者换句话说,花费更多时间来请求数据而不是处理它

我似乎经常遇到的一个很大的误解是,与数据库的通信占用了以数据库为中心的Python应用程序的大部分时间。这可能是编译型语言(如C或甚至Java)中的常见智慧,但通常不在Python中。与这些系统相比,Python 非常慢; 虽然Pypy当然是一个很大的帮助,但在处理标准的CRUD风格的应用程序时(意味着:不运行大型OLAP风格的查询,并且假设网络延迟相对较低),Python的速度并不像数据库那么快。正如我为Openstack做的PyMySQL评估中那样,不管数据库驱动程序(DBAPI)是使用纯Python还是使用C语言编写的,都会导致额外的Python级别的额外开销。仅就DBAPI而言,这可能会慢一个数量级。尽管网络开销会平衡一下CPU和IO之间的比例,但是由于Python驱动程序本身花费的CPU时间仍然是网络IO的两倍,而且这还没有涉及任何额外的数据库抽象库,或者业务逻辑。

这个脚本改编自Openstack条目,展示了一组相当简单的INSERT和SELECT语句,除了准系统显式调用DBAPI外,几乎没有Python代码。

MySQL-Python,纯粹的C DBAPI,通过网络像下面这样运行它:

DBAPI (cProfile):  <module 'MySQLdb'>
     47503 function calls in 14.863 seconds
DBAPI (straight time):  <module 'MySQLdb'>, total seconds 12.962214

使用PyMySQL,一个纯Python DBAPI和通过网络连接,我们的速度降低了大约30%:

DBAPI (cProfile):  <module 'pymysql'>
     23807673 function calls in 21.269 seconds
DBAPI (straight time):  <module 'pymysql'>, total seconds 17.699732

针对本地数据库运行,PyMySQL比MySQLdb慢一个数量级:

DBAPI:  <module 'pymysql'>, total seconds 9.121727

DBAPI:  <module 'MySQLdb'>, total seconds 1.025674

为了突出显示IO开销的实际比例,以下两个RunSnakeRun 展示了使用PyMySQL连接本地数据库和网络数据库运行的实际IO时间。网络连接的比例不是很明显的大,在这种情况下,网络调用仍只占总时间的1/3。其他三分之二用于Python处理结果。请记住,这 仅仅是DBAPI ; 一个真实世界的应用程序将具有数据库抽象层,还有围绕这些调用的业务逻辑:

.. <center>本地连接 - 显然不是IO密集的</center >

.. <center>网络连接 - 虽然没那么夸张,但仍然不是IO密集的(对于整个执行的24秒,套接字时间为8.7秒)</center >

在这里我们要明确一点,在使用Python调用数据库时,除非您尝试使用大量结果集进行大量复杂的分析调用(这些调用通常不会在高性能应用程序中执行),或者除非您的网络速度非常慢,通常不会产生IO密集效应。当我们谈论数据库时,我们几乎总是使用某种形式的连接池,所以连接的开销已经在很大程度上减轻了;在合理的网络状况下,数据库本身可以非常快地查找和插入少量的行。Python自身的开销,仅仅为了序列化消息并产生结果集,就给CPU提供了大量的工作去做,从而消除了非阻塞IO带来的任何独特的吞吐量优势。而对于实际场景中的数据库操作,花在CPU上的开销只增不减。

2. 一个使用AsyncIO有趣,但相对低效的Python范例

asyncio的核心是我们使用 @asyncio.coroutine 装饰器,它会使用一些生成器技巧,以便让您的其他同步函数服从其他协程。其核心是yield from,这会导致函数在该点停止执行,而其他部分持续执行,直到事件循环回到这一点。这是一个好主意,也可以使用更常见的 yield 语句来完成。但是使用 yield from ,我们可以获得返回值:

@asyncio.coroutine
def some_coroutine():
    conn = yield from db.connect()
    return conn

这个语法很棒,我非常喜欢它,但不幸的是,return conn语句的机制必然会引发StopIteration 异常。而且yield from多多少少会增加函数调用的开销。我曾发推做了一个简单的演示,这里是简略形式:

def return_with_normal():
    """One function calls another normal function, which returns a value."""

    def foo():
        return 5

    def bar():
        f1 = foo()
        return f1

    return bar

def return_with_generator():
    """One function calls another coroutine-like function,
    which returns a value."""

    def decorate_to_return(fn):
        def decorate():
            it = fn()
            try:
                x = next(it)
            except StopIteration as y:
                return y.args[0]
        return decorate

    @decorate_to_return
    def foo():
        yield from range(0)
        return 5

    def bar():
        f1 = foo()
        return f1

    return bar

return_with_normal = return_with_normal()
return_with_generator = return_with_generator()

import timeit

print(timeit.timeit("return_with_generator()",
    "from __main__ import return_with_generator", number=10000000))
print(timeit.timeit("return_with_normal()",
    "from __main__ import return_with_normal", number=10000000))

这个演示的结果表明了,一个什么都不做的yield from+StopIteration花了6倍的时间。

yield from: 12.52761328802444
normal: 2.110536064952612

许多人对我说,“那又怎么,你的数据库调用花费的时间要比这多得多”。不要在意这里,我们不是在谈论优化现有代码的方法,而是为了防止使完美的代码变得更慢。PyMySQL示例应该说明,Python的开销增长非常快,即使是一个纯Python的数据库驱动,数据库本身花费的时间也会相形见绌,但是,这个论点可能仍然不够有说服力。

因此,我将在这里介绍一个全面的测试套件,它演示了Python中针对asyncio的传统线程以及gevent样式的非阻塞IO。我们将使用psycopg2,它是目前唯一支持异步的生产环境DBAPI,与aiopg一起使用,可以将psycopg2的异步支持与asyncio相匹配,而psycogreen将与gevent相匹配。

测试套件的目的是尽可能快地将数百万行加载到Postgresql数据库中,同时使用相同的一般SQL指令,以便我们可以看到实际上GIL是否会让我们放慢速度,以致于让asyncio轻松的“飞过去”。该套件可以同时使用任意数量的连接; 最高我使用了350个并发连接,相信我,这不会让你的DBA幸福可言。

README文件底部汇总了在不同条件不同机器上运行的几次结果。我能得到的最佳性能是在一台笔记本电脑上运行Python代码,与另一台笔记本电脑上的Postgresql数据库连接,但几乎在每次测试中,不管我运行15个线程/协程在Mac上,还是350个(!)线程/协程在我的Linux笔记本电脑上,线程版本代码在各种情况下(包括350线程的情况下,我都惊呆了)都比asyncio快得多,并且通常比gevent更快。以下是在Linux笔记本上运行120个线程/进程/连接运行在Mac笔记本电脑上Postgresql数据库的结果:

Python2.7.8 threads (22k r/sec, 22k r/sec)
Python3.4.1 threads (10k r/sec, 21k r/sec)
Python2.7.8 gevent (18k r/sec, 19k r/sec)
Python3.4.1 asyncio (8k r/sec, 10k r/sec)

在上面,我们看到asyncio在运行的第一部分显着较慢(Python 3.4在线程和asyncio中似乎都有一些问题),第二部分的速度比使用线程慢两倍,Python2.7和Python3.4都是。即使运行350个并发连接,这比您通常希望单个进程运行的要多得多,asyncio几乎无法达到线程的效率。即使使用非常快速且纯粹的C代码psycopg2驱动程序,先是aiopg库的开销,再加上python轮询psycopg2的异步库结果的开销,就足以拖慢脚本的运行速度。

请记住,我甚至没有试图证明asyncio比线程慢得多,只是它没有更快。我得到的结果比我预期的还要戏剧化。我们还看到,使用非常低延迟的异步方法,比如gevent也比线程慢,但不是太多,这首先证实异步IO在这种情况下肯定不会更快,但也因为asyncio比gevent慢许多,实际上asyncio的协程和其他Python结构的开销,可能会在低延迟情况下,给基于IO的上下文切换增加了非常显着的额外延迟。

问题2 - 异步使编码更容易

这是“性能魔法”硬币的另一面。这个论点扩展了“线程是坏的”修辞,并且最极端的形式是,如果某个层次的程序碰巧产生了一个线程,例如,如果你编写了一个WSGI应用程序,并且恰巧使用线程池在mod_wsgi下运行它,你现在正在进行“多线程编程”,这与在整个代码中进行POSIX线程练习一样困难。尽管WSGI应用程序不应该有任何与进程内共享和可变状态有关的任何事情,但是,你正在做线程编程,线程很难,你应该停下来。

“线程不好”的说法有一个有趣的转折(ha!),这就是说,它被显式的异步提倡者用来反对隐式的异步技术。 Glyph的文章很好的阐述了这一点。前提是,如果你已经接受基于线程的并发是一件坏事,那么使用隐式样式的异步IO同样不好,因为最终代码看起来与线程代码相同,并且因为IO可以在任何地方发生,就像使用传统线程一样不确定。我正好也赞同这个观点,是的,类似gevent这样的并发系统和线程一样不好,甚至更糟。其中一个原因是,由于GIL的存在,Python的多线程编程相当“软”,GIL能够让一些灾难性操作(比如给list进行append操作)变得安全。但是使用绿线程, 会遇到一些传统的,受GIL保护的线程不可能遇到的奇怪问题

顺便说一句,需要注意的是Glyph对这类情况做的阐述,

遗憾的是,“异步”系统经常通过强调一些可疑的优化来传播,这种优化允许比抢占式线程更高级别的I/O密集的并发,而不是我在上面已经解释过的线程编程模型。通过以这种方式塑造“异步”,将所有4个选择集中在一起是有意义的。

我自己也对此感到内疚,特别是在过去的几年里,总是说,使用Twisted的系统比使用线程的替代方案效率更高。在许多情况下,这是真的,但是:

  1. 在性能方面,情况几乎总是比这更复杂,
  2. “上下文切换”在现实世界的程序中很少成为瓶颈
  3. 这有点分散了事件驱动编程的更大优势,简单地说就是在两种意义上(即包含大量代码的程序以及具有许多并发用户的程序)大规模编写程序更容易。

当人们想要谈谈切换到asyncio会如何减少程序中的错误,并且承诺更好的性能时,他们会引用Glyph的帖子,但是因为某些原因,选择性忽略了这篇很棒的帖子中的这一部分。

应该使用非阻塞IO,而且应该是显式的,Glyph对于这两个论点提出了良好的,明确的论据。但是这种论据和非阻塞IO的起源无关,因为它是处理来自大量、慢速连接的数据的合理方式。它是事件循环的本质,一个全新的并发模型,消除了暴露OS级上下文切换的需要。

虽然我们从编写回调以来已经走了很长一段路,并且现在可以使用asyncio这类的方法,编写看起来非常线性的代码,但该方法仍然需要程序员明确指定所有会发生IO的函数调用。下面是一个示例:

def transfer(amount, payer, payee, server):
    if not payer.sufficient_funds_for_withdrawal(amount):
        raise InsufficientFunds()
    log("{payer} has sufficient funds.", payer=payer)
    payee.deposit(amount)
    log("{payee} received payment", payee=payee)
    payer.withdraw(amount)
    log("{payer} made payment", payer=payer)
    server.update_balances([payer, payee])

这个并发程序的错误在于,在多线程的角度看来,如果两个线程都运行transfer()函数,他们可能都会从InsufficientFunds下边的payer这里取钱,而不会抛出异常。

下边是一个显示的异步的版本:

@coroutine
def transfer(amount, payer, payee, server):
    if not payer.sufficient_funds_for_withdrawal(amount):
        raise InsufficientFunds()
    log("{payer} has sufficient funds.", payer=payer)
    payee.deposit(amount)
    log("{payee} received payment", payee=payee)
    payer.withdraw(amount)
    log("{payer} made payment", payer=payer)
    yield from server.update_balances([payer, payee])

现在,在我们所处的流程范围内,我们知道当我们从server.update_balances()调用yield时,程序只会在底部这里执行。 当我们进入函数体并且尚未到达server.update_balances()调用时,任何其他并发调用payer.withdraw()都是不可能的。

然后,他明确指出为什么即使是隐式gevent风格的异步也不够。 因为在上面的程序中,payee.deposit()payer.withdraw()没有使用yield from,我们可以确信在未来的调用中,在我们的transfer()执行完成之前,不会有破坏调度的IO出现。

(另外,我实际上并不确定,由于“我们必须敲下yield from,这样我们就知道发生了什么”,为什么yield from必须成为程序结构的一部分,而不仅仅是,比如说集成gevent/eventlet的代码检查工具,测试IO调用栈,并且验证相应的源代码是否已使用特殊注释进行注释,因为这样可以产生相同的效果,而不会影响外部的任何库,这个系统并没有产生显式异步的所有Python性能开销。但这又是一个话题了。)

不管显式协程的风格如何,这种方法都存在两个缺陷。

一个是异步io让我们轻易的敲下yield from,因为他能让我们少犯错误。这个观点不是很合理,关于这个,Hacker News的一位评论者就异步代码更容易调试提出了自己的看法

基本上,“我想要在我的代码里使用显示的上下文切换代码,否则,理解的难度会指数级的增加”

而且我认为这是一个稻草人论证。作者声称线程代码的所有内容都适用于任何可重入代码,多线程或非线程代码。如果你的函数无意中调用了一个递归调用原始函数的函数,你就会遇到完全相同的问题。

但是,你猜怎么着,这种情况不会经常发生。大多数代码都不是可重入的。大多数的状态都没有分享。

对于并发且以有趣的方式进行交互的代码,你将不得不仔细地对其进行推理。在整个代码中乱写yield from并不能解决问题。

实际上,你最终会在你的代码中得到大量的yield from行,你回到“好吧,我想我可以在任何地方切换上下文”,这是你首先要避免的问题。

在我的测试代码里,你可以看到最后一点是完全正确的,下面是一个线程版本:

cursor.execute(
    "select id from geo_record where fileid=%s and logrecno=%s",
    (item['fileid'], item['logrecno'])
)
row = cursor.fetchone()
geo_record_id = row[0]

cursor.execute(
    "select d.id, d.index from dictionary_item as d "
    "join matrix as m on d.matrix_id=m.id where m.segment_id=%s "
    "order by m.sortkey, d.index",
    (item['cifsn'],)
)
dictionary_ids = [
    row[0] for row in cursor
]
assert len(dictionary_ids) == len(item['items'])

for dictionary_id, element in zip(dictionary_ids, item['items']):
    cursor.execute(
        "insert into data_element "
        "(geo_record_id, dictionary_item_id, value) "
        "values (%s, %s, %s)",
        (geo_record_id, dictionary_id, element)
    )

下面是异步io版本:

yield from cursor.execute(
    "select id from geo_record where fileid=%s and logrecno=%s",
    (item['fileid'], item['logrecno'])
)
row = yield from cursor.fetchone()
geo_record_id = row[0]

yield from cursor.execute(
    "select d.id, d.index from dictionary_item as d "
    "join matrix as m on d.matrix_id=m.id where m.segment_id=%s "
    "order by m.sortkey, d.index",
    (item['cifsn'],)
)
rows = yield from cursor.fetchall()
dictionary_ids = [row[0] for row in rows]

assert len(dictionary_ids) == len(item['items'])

for dictionary_id, element in zip(dictionary_ids, item['items']):
    yield from cursor.execute(
        "insert into data_element "
        "(geo_record_id, dictionary_item_id, value) "
        "values (%s, %s, %s)",
        (geo_record_id, dictionary_id, element)
    )

请注意它们看起来完全一样吗?存在yield from并没有以任何方式改变我编写的代码或我做出的决定 - 这是因为在无聊的数据库代码中,我们基本上需要按顺序执行我们需要执行的查询。我不会尝试将一个智能,周到的进程内并发系统编写成我是如何调用数据库的,或者当我碰巧需要数据库数据时尝试重新调整用途,以此来锁定其他部分。如果我需要数据,我就会调用它。

不管这是否引人注目都不重要 - 在我们的程序中使用异步或互斥或者其他任何东西来控制并发在任何情况下都是完全不够的。相反,我们绝对必须始终在并发的真实世界的“无聊的数据库”代码中做的事情是:

数据库代码通过ACID处理并发,而不是进程内同步

无论我们是否使用线程代码,或者带有隐式或显式IO的协程,并找到在我们的进程中发生的所有竞态条件,如果我们说的是关系数据库,那就不重要了,尤其是在今天的世界中,一切都以集群/横向扩展/分布式的方式运行 - 学术理论家们对线程的非确定性本质的处理只是冰山一角;我们需要处理完全不同的过程,无论如何说,非确定性都会存在。

对于数据库代码,你只需要使用一种技术来确保并发的正确性,即使用面向ACID的结构和技术。不幸的是,这些并不是神奇地或通过任何已知的银弹来实现的,尽管有很棒的工具可以帮助你引导你朝着正确的方向前进。

For database code, you have exactly one technique to use in order to assure correct concurrency, and that is by using ACID-oriented constructs and techniques. These unfortunately don't come magically or via any known silver bullet, though there are great tools that are designed to help steer you in the right direction.

从数据库的角度来看,上面的所有示例transfer()函数都是错误的。这才是正确的:

def transfer(amount, payer, payee, server):
    with transaction.begin():
        if not payer.sufficient_funds_for_withdrawal(amount, lock=True):
            raise InsufficientFunds()
        log("{payer} has sufficient funds.", payer=payer)
        payee.deposit(amount)
        log("{payee} received payment", payee=payee)
        payer.withdraw(amount)
        log("{payer} made payment", payer=payer)
        server.update_balances([payer, payee])

看出来区别了吗?在上面,我们使用了事务。基于SELECT来获取付款方的余额,然后使用autocommit修改余额是完全错误的。我们必须确保取值的时候使用了适当的锁定系统,以便从我们读取到写入的时间内,我们不可能基于一个过期的值来修改。我们可能使用SELECT .. FOR UPDATE来锁定我们打算修改的行。或者我们可能使用read committed隔离级别和版本计数器的组合来实现一种乐观方法,以便函数在发生竞态条件的时候会执行失败。但是,我们在单进程中使用线程,greenlet或者任何并发机制,决不会对我们在这里使用的策略产生任何影响;我们的并发问题只涉及完全独立的进程的交互。

总结!

请注意我并不是说你不应该使用asyncio。我认为它做得非常好,使用起来很有趣,而且我仍然对它和SQLAlchemy的故事感兴趣,因为我确信不管别人怎么说,还是有人想要这个。

我的观点是,当涉及到老套的数据库逻辑时,使用它与传统的线程方法相比没有优势,并且性能会有小到中度的降低,而不是增加我的很多同事都知道这些,但最近我不得不辩论这一点。

如果想要获得非阻塞IO的优势来接收Web请求,而不需要将其业务逻辑转换为显式异步,那么理想的集成场景就是nginx与uWsgi的简单组合。

<small>由于拖延症,这篇文章前后翻译了好几个月。水平有限,不足之处恳请在评论或者发邮件指出</small>

25,090 views, since 2018-11-07