行文逻辑:
- 首先介绍Linus的工作方式,异步沟通,来从日常工作引入同步/异步的概念,同时说明阻塞/非阻塞的特点
- 然后说明python解释器的同步工作是怎样的,尤其注意GIL和缓存的作用
- 从介绍回调说明为什么一直说异步回调,同时配合任务队列才更香。注意里面的{任务队列}还有专门的分布式方式
- 异步为什么需要事件循环(Event-loop)
- 异步来源于生活,GTD的理念
1. Linus喜欢异步沟通
作为Linux的创始人,Linus Torvalds也算在程序员中大名鼎鼎的人物了。他的技术水平毋庸置疑,而且他也有很丰富的远程办公经验了。先不说其他的,比如跑步办公桌,他一直执行的‘异步沟通’方式就值得称道。既然都在家办公了,如果还是要随时在线能沟通,那也就没有自由可言了,无非是换了个办公室。Linus一直坚持邮件沟通,其他人可以发邮件,确保能被收到,但是不保证马上就有回复。
这一点就能比较出同步沟通方式:打电话,面对面沟通,视频通话。这些都要求一方发起活动后,对方马上有回应,不然就会卡在那里(这就叫阻塞了)。再举个例子,快递柜。有了快递柜就能进行‘异步收快递’。快递员把包裹直接放在柜子里,且有消息通知对方,而对方什么时候去取就不用快递员关心了。这样就把面对面交接包裹的同步方式变成了异步方式。
那么对于同步和异步,我给出自己的一个简单的理解。同步就是只有0和1两个状态,只有没做和完成两种,没有中间的状态。0和1之间只是时间长短的问题,中间可以被阻塞,但不能被打断。异步就多了个中间状态,可以在某个地方暂停,切换出去,再切换回来,并恢复离开前的状态,继续处理。如果同步的耗费时间有部分是在I/O上,在等待对方回应的话,这才有必要用异步切换出去等对方准备好了再切换回来。所以从I/O耗时这个方面,才需要考虑同步和异步。
这里也就引出了并发(concurrency),它的本质就是频繁的切换,一旦某处需要等待,就切换到其他地方,反正不能让CPU闲着。最后就造成了看起来是多处一起进行的’假象‘.
如果不是I/O密集型,而是那种计算密集型又该怎么办?很明显,I/O占比少的操作,用异步是没用的。这时就是堆性能,堆CPU个数,于是要推出并行(paralism). 解决方式是多进程/分布式。
总结一下:同步与异步的核心区别就是能否被打断和恢复,而是否用异步的方式编程,取决于业务逻辑里面I/O操作有多少。
接着说说阻塞和非阻塞,很明显,同步就只谈阻塞了,因为中间又不能打断,那么某个I/O操作就只能干等着。而异步就谈非阻塞才有意义,你可以捋一下为什么。
2. 程序猿习惯顺序编程,解释器喜欢线性执行
其实我们大部分情况下写的代码都是同步形式的,也可以说是顺序执行方式。一条条执行下去
start ----> code block A ----> code block B ----> code block C ----> end
在解释器/编译器/引擎看来,也不过是把那些函数体替换到这个位置来执行。所以也可以看出,顺序编程也是最符合解释器的执行习惯的。顺序执行,然后在某一个地方报错了,修改好了,继续执行,这都已经是基本操作了。
而python里面著名的‘GIL’就是基于异步并发的需要来的。首先需要明确,GIL只是cpython这个python解释器里面才会存在的,比如性能更高的PyPy就没有这个问题。而在Cpython中就有专门对GIL的描述
GIL in threadsopengrok.flowingnote.comThe Python interpreter is not fully thread-safe. In order to support
multi-threaded Python programs, there's a global lock, called the :term:`global
interpreter lock` or :term:`GIL`, that must be held by the current thread before
it can safely access Python objects. Without the lock, even the simplest
operations could cause problems in a multi-threaded program: for example, when
two threads simultaneously increment the reference count of the same object, the
reference count could end up being incremented only once instead of twice.
.. index:: single: setswitchinterval() (in module sys)
Therefore, the rule exists that only the thread that has acquired the
:term:`GIL` may operate on Python objects or call Python/C API functions.
In order to emulate concurrency of execution, the interpreter regularly
tries to switch threads (see :func:`sys.setswitchinterval`). The lock is also
released around potentially blocking I/O operations like reading or writing
a file, so that other Python threads can run in the meantime.
可见,GIL主要在于禁止多线程并行,注意不是并发。主要因为Cpython解释执行代码的时候不是线程安全的,所以需要GIL这个全局锁来确保不会同时有多个线程访问某个对象,调用某个方法,造成数据被修改之类的问题。而有了GIL之后,哪怕是在多线程代码中,某个线程要获取执行的权限,就需要先获取GIL。那么很容易得出结论,全局锁最适合的是I/O操作中的等待的时候切换线程去执行。
而对于真正需要并行的操作,就只能考虑多进程,比如使用进城池。因为一个进程可以启用一个cpython解释器,从而获得一个GIL。这里就能看出,进程需要的资源比线程多不少。
而缓存的作用在异步这里尤为有效。因为已经说过,异步主要是在I/O阻塞的时候来回切换线程时使用。但是,不论切换需要消耗的资源有多少,总归要消耗,而如果有了缓存,比如Redis这种直接在内存中的数据库,就没有了I/O阻塞切换的需要,能减少切换的次数。
3. 异步有了回调,才更加完整
- 调用(call)与回调(callback):
首先,我们很清楚,调用就是在某个函数A里面的某一行写入了函数B的名字,并传入对应参数进行操作:
def
看图说话:比较一下调用和回调的执行图,很明显的差别在于多了callback这个引用指向了called函数。虽然只是多了这一步,但是就和普通调用不一样了。普通调用是在代码里面写死了,而回调则是可以很灵活地传入不同函数来调用。不要被回调这个词给迷惑了,它其实只不过是灵活调用的意思。
而稍后调用的时候,有个问题不得不考虑,那就是参数如何传递。
- 函数签名:
函数签名就是指函数定义时定义的需要传入的参数类型
def <function name>(<function signature>):
""" documentation string """
<encapsulated code>
return <object>
它一般用来检查函数传入参数是否正确,然后才开始调用
In [22]: def foo(position, *args, **kwargs):
...: print('position arg:', position)
...: for arg in args:
...: print(arg)
...: for key, value in kwargs.items():
...: print('{key}:{value}'.format(key=key, value=value))
...:# from inspect import signature
In [24]: foo.__annotations__
Out[24]: {}
In [25]: f_sig = signature(foo)
In [26]: f_sig
Out[26]: <Signature (position, *args, **kwargs)>
- 以python的内置异步库为例,它的future就有个添加回调方法的地方。
def add_done_callback(self, fn, *, context=None):
"""Add a callback to be run when the future becomes done.
The callback is called with a single argument - the future object. If
the future is already done when this is called, the callback is
scheduled with call_soon.
"""
if self._state != _PENDING:
self._loop.call_soon(fn, self, context=context)
else:
if context is None:
context = contextvars.copy_context()
self._callbacks.append((fn, context))
fn就是指传入的回调方法,*代表传入的参数,但是基本没用上,它只能传一个future对象
但是倘若只是这点区别,似乎对函数的执行也没什么区别。它们都在called没有执行完之前被阻塞在那里,不能继续执行,这种就叫阻塞回调。而回调最大的价值还在于非阻塞回调,又叫异步回调:调用函数caller执行到callback的时候,不需要阻塞,可以直接执行下去,等callback指向的called函数执行完之后再回过头来执行。
这才是回调这个词的用意所在,因此平时我们提到的回调都指的是异步非阻塞回调。
回调与任务队列
这里每个去异步执行,并带有回调的独立操作都可以称为任务。但是如果一个个任务都去开个线程/进程来执行,然后关闭,在CPU切换上下文的过程中就浪费很多资源。比如你有10000个任务需要执行,这种开销就值得考虑了。出于整理设计的理念,把这些任务都放到队列里面去排队执行,然后有若干个线程不断从队列里面取任务执行,这种设计就是任务队列了。
其实在开发中,任务队列就有专门的组件,比如celery。这里就可以发现,队列被包括在异步编程里面的原因了。
而任务队列还可以有多个,因为任务执行的时间,I/O操作有不同,可以把耗时长,或者不需要立即返回结果的任务单独放一个队列。而这种多队列自然需要一个分配者,也就是broker。所以celery一般都会配合Redia/Rabbitmq
事实上,随着任务增多,想要提高效率,自然是工人越多越好,这个时候就可以上分布式,由不同机器上面的celery连接到broker,去获取任务。
上面说了这么多,其实这就是设计模式中的“观察者模式”
发布 - 订阅 , 变化 - 更新
4. 事件循环♻️(Event-loop)在异步中的作用:单线程异步高性能
前面提到了,GIL主要是用来防止多线程并行的情况,因为Cpython不是线程安全的解释器。那么我们在用多线程的时候,必须主动用线程锁/信号量来保证多线程协同工作的时候不会相互覆盖某个资源。但是这种机制来切换线程还是会浪费不少资源,为了进一步提高效率,python专门从生成器的yield里面推出了可以传入值的“加强版生成器”,也就是协程。具体协程是如何来的,可以看这篇文章:
观笑过:<异步一>:一看就懂!从可迭代演化到协程,开启异步编程zhuanlan.zhihu.com到了协程这一步,程序员已经可以在代码中主动去控制协程来进行异步操作了,但是这个还远远不够,因为如果是有多个协程要运行,这个时候对协程的管理就是新的问题。于是事件循环模型(Event-loop)就出来了。
- 事件循环与回调、队列的关系
前面提到了多任务队列的设置,但是这都是基于多线程,多workers。而python本身是基于单线程的, 可以基于执行状态区分为两个队列:当前执行队列和稍后回调队列。一个线程不断地在当前执行队列里面获取任务执行,如果需要回调,就先放到稍后回调队列。等当前执行队列里面的任务都完成了,再去稍后回调里面把任务一一取出来,放到当前执行队列。如此基于事件状态的循环执行方式,就可以实现单线程的异步操作,达到单线程异步高性能。这就叫做事件循环(Event-loop),而且也是Redis,Nginx等工具的核心理念。
- 首先用代码来实现一个简单的事件循环模式:
from
从上面可以看出,event的不同状态(pending,execute)让它在Call Stack和Task Queue之间循环。这就是事件循环的由来。而且这种循环是非阻塞的。
5. GTD其实就是事件循环:收件箱+回顾+执行栈
其实不仅仅是电脑处理事情需要异步来提高I/O操作时的效率,人脑处理事情也有类似的情况。因为人脑其实和解释器一样,只能一件件地去做事,顶多只能做到‘并发’, 并行地做几件事是不可能的。哪怕你是边听音乐边写代码,也只不过是享受音乐,其实歌词是听不到多少的。
所以很多做事方法就着重在如何高效地做事,比如著名的GTD(Getting Things Done)就是一个类似于Event-loop的做事方式。它主要流程就是:
- 收件箱
当你有什么事情要做的时候,最好不是马上就放下手上的事情去做,而是先记录下来。而专门记录的地方就是收件箱。你通过把脑中所想而又不能马上做的事情记录下来,同时清理了人脑中的‘缓存’。就像《稀缺》这本书提到的那样,人脑的心智资源是宝贵而有限的,你可以通过清理心绪可以释放这些心智资源,来全力高效地把眼前要做的事情做好。所以收件箱就类比于上面驱动模型里面的Task Queue,存放将要做的事情
这就对应了代码里面的events_to_listen
- 回顾
把要做的事情记录好了,又把手上的事情做完了,当然就该休息一下,劳逸结合嘛。但是等下次资源齐备后,就该先开始回顾一下之前记录下来的事情。GTD同样有上下文这个概念,具体就是指的时间/地点/人物/设备等做事需要的条件。在回顾的过程中,就要结合当前的上下文条件,从收件箱里面选择当前环境下可以做的事情。选择好后就放在执行栈里,等待下一步做事的时候一件件完成
这就对应了上面挑选要做的事的那个流程poll_events
- 执行栈
这个词有两个意思,执行就是指的要具体去做;而栈指的是要一件件完成,考虑做不做的这个过程应该是在回顾那个阶段,这个时候的目标就是把事情一件件完成,而不是跳过,挑三拣四地去做。当然,如果在做的过程中发现有些条件其实还不具备,那么就是要把相关内容记录下来,然后重新放入收件箱,等待下一次回顾的时候再考虑是否放入执行栈。
另外,在这个过程中,如果有什么新的想法,就要马上记录到收件箱里,不要浪费精力去分神。
这就对应了代码里面的_process_events
当然,真正做事的过程中,根据不同的上下文还会有复杂点的处理方式。比如根据办公室+电脑+咖啡的上下文,就可以组合出一个写代码要完成的待办清单;而根据’家里‘这个条件,清单里面就可能出现做家务,洗碗,洗衣服,整理房间这些事情。但是上下文一般不要频繁切换,毕竟你也不能老是家里和公司两头跑(正常人都不会想这样子做事的)。CPU的上下文切换也是如此。
所以我就习惯用Omnifocus这款app来安排要做的事情,因为它就有这种通过上下文来建立‘透视’清单的功能。