切面是异步还是同步操作‘_<异步·启>:来源于生活而又高于生活|摸清异步的来龙去脉...

aea3abbd53ad306ee35eb345ae68a85f.png

行文逻辑:

  • 首先介绍Linus的工作方式,异步沟通,来从日常工作引入同步/异步的概念,同时说明阻塞/非阻塞的特点
  • 然后说明python解释器的同步工作是怎样的,尤其注意GIL和缓存的作用
  • 从介绍回调说明为什么一直说异步回调,同时配合任务队列才更香。注意里面的{任务队列}还有专门的分布式方式
  • 异步为什么需要事件循环(Event-loop)
  • 异步来源于生活,GTD的理念

1. Linus喜欢异步沟通

57ea111ff332cd91a531c81bffb8b115.png

作为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 threads​opengrok.flowingnote.com
The 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):
Wiki: Callback​en.wikipedia.org

42aae32392cd944348db270064fa0c2c.png

首先,我们很清楚,调用就是在某个函数A里面的某一行写入了函数B的名字,并传入对应参数进行操作:

def 

e2f59ede755fc7fe560043b3d264ed79.png

13cf5d04077eef84bec4b0671885ac9d.png
看图说话:比较一下调用和回调的执行图,很明显的差别在于多了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就有个添加回调方法的地方。
futures.py in cpython​opengrok.flowingnote.com
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。这里就可以发现,队列被包括在异步编程里面的原因了。

655879d416ab71426d76d6b1b3edec47.png

而任务队列还可以有多个,因为任务执行的时间,I/O操作有不同,可以把耗时长,或者不需要立即返回结果的任务单独放一个队列。而这种多队列自然需要一个分配者,也就是broker。所以celery一般都会配合Redia/Rabbitmq

65e931f607e3979cb7be4ab09b3d566e.png
事实上,随着任务增多,想要提高效率,自然是工人越多越好,这个时候就可以上分布式,由不同机器上面的celery连接到broker,去获取任务。

上面说了这么多,其实这就是设计模式中的“观察者模式”

发布 - 订阅 , 变化 - 更新

4. 事件循环♻️(Event-loop)在异步中的作用:单线程异步高性能

前面提到了,GIL主要是用来防止多线程并行的情况,因为Cpython不是线程安全的解释器。那么我们在用多线程的时候,必须主动用线程锁/信号量来保证多线程协同工作的时候不会相互覆盖某个资源。但是这种机制来切换线程还是会浪费不少资源,为了进一步提高效率,python专门从生成器的yield里面推出了可以传入值的“加强版生成器”,也就是协程。具体协程是如何来的,可以看这篇文章:

观笑过:<异步一>:一看就懂!从可迭代演化到协程,开启异步编程​zhuanlan.zhihu.com
3e9b70be7b8400ba7def1244fd32a3e5.png

到了协程这一步,程序员已经可以在代码中主动去控制协程来进行异步操作了,但是这个还远远不够,因为如果是有多个协程要运行,这个时候对协程的管理就是新的问题。于是事件循环模型(Event-loop)就出来了。

  • 事件循环与回调、队列的关系

前面提到了多任务队列的设置,但是这都是基于多线程,多workers。而python本身是基于单线程的, 可以基于执行状态区分为两个队列:当前执行队列和稍后回调队列。一个线程不断地在当前执行队列里面获取任务执行,如果需要回调,就先放到稍后回调队列。等当前执行队列里面的任务都完成了,再去稍后回调里面把任务一一取出来,放到当前执行队列。如此基于事件状态的循环执行方式,就可以实现单线程的异步操作,达到单线程异步高性能。这就叫做事件循环(Event-loop),而且也是Redis,Nginx等工具的核心理念。

554e7560e30aae81c70562e363070f6a.png
  • 首先用代码来实现一个简单的事件循环模式:
from 
从上面可以看出,event的不同状态(pending,execute)让它在Call Stack和Task Queue之间循环。这就是事件循环的由来。而且这种循环是非阻塞的。

5. GTD其实就是事件循环:收件箱+回顾+执行栈

660191392648aa282321f8d688ed702d.png

其实不仅仅是电脑处理事情需要异步来提高I/O操作时的效率,人脑处理事情也有类似的情况。因为人脑其实和解释器一样,只能一件件地去做事,顶多只能做到‘并发’, 并行地做几件事是不可能的。哪怕你是边听音乐边写代码,也只不过是享受音乐,其实歌词是听不到多少的。

所以很多做事方法就着重在如何高效地做事,比如著名的GTD(Getting Things Done)就是一个类似于Event-loop的做事方式。它主要流程就是:

  • 收件箱

当你有什么事情要做的时候,最好不是马上就放下手上的事情去做,而是先记录下来。而专门记录的地方就是收件箱。你通过把脑中所想而又不能马上做的事情记录下来,同时清理了人脑中的‘缓存’。就像《稀缺》这本书提到的那样,人脑的心智资源是宝贵而有限的,你可以通过清理心绪可以释放这些心智资源,来全力高效地把眼前要做的事情做好。所以收件箱就类比于上面驱动模型里面的Task Queue,存放将要做的事情

这就对应了代码里面的events_to_listen
  • 回顾

把要做的事情记录好了,又把手上的事情做完了,当然就该休息一下,劳逸结合嘛。但是等下次资源齐备后,就该先开始回顾一下之前记录下来的事情。GTD同样有上下文这个概念,具体就是指的时间/地点/人物/设备等做事需要的条件。在回顾的过程中,就要结合当前的上下文条件,从收件箱里面选择当前环境下可以做的事情。选择好后就放在执行栈里,等待下一步做事的时候一件件完成

这就对应了上面挑选要做的事的那个流程poll_events
  • 执行栈

这个词有两个意思,执行就是指的要具体去做;而栈指的是要一件件完成,考虑做不做的这个过程应该是在回顾那个阶段,这个时候的目标就是把事情一件件完成,而不是跳过,挑三拣四地去做。当然,如果在做的过程中发现有些条件其实还不具备,那么就是要把相关内容记录下来,然后重新放入收件箱,等待下一次回顾的时候再考虑是否放入执行栈。

另外,在这个过程中,如果有什么新的想法,就要马上记录到收件箱里,不要浪费精力去分神。

这就对应了代码里面的_process_events

当然,真正做事的过程中,根据不同的上下文还会有复杂点的处理方式。比如根据办公室+电脑+咖啡的上下文,就可以组合出一个写代码要完成的待办清单;而根据’家里‘这个条件,清单里面就可能出现做家务,洗碗,洗衣服,整理房间这些事情。但是上下文一般不要频繁切换,毕竟你也不能老是家里和公司两头跑(正常人都不会想这样子做事的)。CPU的上下文切换也是如此。

所以我就习惯用Omnifocus这款app来安排要做的事情,因为它就有这种通过上下文来建立‘透视’清单的功能。


由此可见,异步这个操作方式,还有事件循环,其实也算来源于生活,或者说应用于生活。这样结合起来的好处不仅能提高其他方面的水平,也能深刻理解这些概念

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值