关于python3.x:并发异步编程之争协程asyncio到底需不需要加锁线程协程安全挂起主动切换Python3

3次阅读

共计 5556 个字符,预计需要花费 14 分钟才能阅读完成。

原文转载自「刘悦的技术博客」https://v3u.cn/a_id_208

协程与线程向来焦孟不离,但事实上是,线程更被咱们所熟知,在 Python 编程畛域,单核同工夫内只能有一个线程运行,这并不是什么缺点,这实际上是合乎主观逻辑的,单核处理器原本就没法同时解决两件事件,要同时进行多件事件原本就须要正在运行的让出处理器,而后能力去解决另一件事件,左手画方右手画圆在事实中原本就不成立,只不过这个让出的过程是线程调度器被动抢占的。

线程平安

零碎的线程调度器是假如不同的线程是毫无关系的,所以它均匀地调配工夫片让处理器厚此薄彼,雨露均沾。然而 Python 受限于 GIL 全局解释器锁,任何 Python 线程执行前,必须先取得 GIL 锁,而后,每执行 100 条字节码,解释器就主动开释 GIL 锁,让别的线程有机会执行。这个 GIL 全局解释器锁实际上把所有线程的执行代码都给上了锁,所以,多线程在 Python 中只能交替执行,即便多个线程跑在 8 核解决上,也只能用到 1 个核。

但其实,这并不是事件的全貌,就算只能用单核解决工作,多个线程之前也并不是齐全独立的,它们会操作同一个资源。于是,大家又创造了同步锁,使得一段时间内只有一个线程能够操作这个资源,其余线程只能期待:

import threading  
  
balance = 0  
  
def change_it_without_lock(n):  
    global balance  
    # 不加锁的话 最初的值不是 0  
    # 线程共享数据危险在于 多个线程同时改同一个变量  
    # 如果每个线程按程序执行,那么值会是 0,然而线程时系统调度,又不确定性,交替进行  
    # 没锁的话,同时批改变量  
    # 所以加锁是为了同时只有一个线程再批改,别的线程表肯定不能改  
    for i in range(1000000):  
        balance = balance + n  
        balance = balance - n  
  
def change_it_with_lock(n):  
    global balance  
    if lock.acquire():  
        try:  
            for i in range(1000000):  
                balance = balance + n  
                balance = balance - n  
        # 这里的 finally 避免中途出错了,也能开释锁  
        finally:  
            lock.release()  
  
threads = [threading.Thread(target=change_it_with_lock, args=(8,)),  
    threading.Thread(target=change_it_with_lock, args=(10,))  
]  
  
lock = threading.Lock()  
  
[t.start() for t in threads]  
[t.join() for t in threads]  
  
print(balance)

这种异步编程形式被宽广开发者所认可,线程并不平安,线程操作共享资源须要加锁。然而人们很快发现,这种解决形式是在画龙点睛,处理器原本同一时间就只能有一个线程在运行。是线程调度器抢占划分工夫片给其余线程跑,而当初,多了把锁,其余线程又说我拿不到锁,我得拿到锁能力操作。

就像以前的公共电话亭,原本就只能一个人打电话,当初电话亭上加了把锁,还是只能一个人打电话,而有没有锁,有什么区别呢?所以,问题到底出在哪儿?

事实上,在所有线程互相独立且不会操作同一资源的模式下,抢占式的线程调度器是十分不错的抉择,因为它能够保障所有的线程都能够被分到工夫片不被垃圾代码所连累。而如果操作同一资源,抢占式的线程就不那么让人欢快了。

协程

过了一段时间,人们发现常常须要异步操作共享资源的状况下,被动让出工夫片的协程模式比线程抢占式调配的效率要好,也更简略。

从理论开发角度看,与线程相比,这种被动让出型的调度形式更为高效。一方面,它让调用者本人来决定什么时候让出,比操作系统的抢占式调度所须要的工夫代价要小很多。后者为了能复原现场会在切换线程时保留相当多的状态,并且会十分频繁地进行切换。另一方面,协程自身能够做成用户态,每个协程的体积比线程要小得多,因而一个过程能够包容数量相当可观的协程工作。

import asyncio  
  
balance = 0  
  
async def change_it_without_lock(n):  
  
    global balance  
  
    balance = balance + n  
    balance = balance - n  
  
  
loop = asyncio.get_event_loop()  
  
res = loop.run_until_complete(asyncio.gather(change_it_without_lock(10), change_it_without_lock(8),  
                   change_it_without_lock(2), change_it_without_lock(7)))  
  
print(balance)

从代码构造上看,协程保障了编写过程中的思维连贯性,使得函数(闭包)体自身就无缝放弃了程序状态。逻辑紧凑,可读性高,不易写出错的代码,可调试性强。

但归根结底,单核处理器还是同工夫只能做一件事,所以同一时间点还是只能有一个协程工作运行,它和线程的最次要差异就是,协程是被动让出使用权,而线程是抢占使用权,即所谓的,协程是用户态,线程是零碎态。

同时,如图所示,协程自身就是单线程的,即不会触发零碎的全局解释器锁 (GIL),同时也不须要零碎的线程调度器参加抢占式的调度,防止了多线程的上下文切换,所以它的性能要比多线程好。

协程平安

回到并发竞争带来的平安问题上,既然同一时间只能有一个协程工作运行,并且协程切换并不是零碎态抢占式,那么协程肯定是平安的:

import asyncio  
  
balance = 0  
  
async def change_it_without_lock(n):  
  
    global balance  
  
    balance = balance + n  
    balance = balance - n  
  
    print(balance)  
  
  
loop = asyncio.get_event_loop()  
  
res = loop.run_until_complete(asyncio.gather(change_it_without_lock(10), change_it_without_lock(8),  
                   change_it_without_lock(2), change_it_without_lock(7)))  
  
print(balance)

运行后果:

0  
0  
0  
0  
0  
liuyue:as-master liuyue$

看起来是这样的,无论是执行过程中,还是最初执行后果,都保障了其状态的一致性。

于是,协程操作共享变量不须要加锁的论断开始在坊间流传。

毫无疑问,谁主张,谁举证,下面的代码也充分说明了这个论断的正确性,然而咱们都疏忽了一个客观事实,那就是代码中没有“被动让出使用权”的操作,所谓被动让出使用权,即用户被动触发协程切换,那到底怎么被动让出使用权?应用 await 关键字。

await 是 Python 3.5 版本开始引入了新的关键字,即 Python3.4 版本的 yield from,它能做什么?它能够在协程外部用 await 调用另一个协程实现异步操作,或者说的更简略一点,它能够挂起以后协程工作,去手动异步执行另一个协程,这就是被动让出“使用权”:

async def hello():  
    print("Hello world!")  
    r = await asyncio.sleep(1)  
    print("Hello again!")

当咱们执行第一句代码 print(“Hello world!”) 之后,应用 await 关键字让出使用权,也能够了解为把程序“临时”挂起,此时使用权让出当前,别的协程就能够进行执行,随后当咱们让出使用权 1 秒之后,当别的协程工作执行结束,又或者别的协程工作也“被动”让出了使用权,协程又能够切回来,继续执行咱们以后的工作,也就是第二行代码 print(“Hello again!”)。

理解了协程如何被动切换,让咱们持续之前的逻辑:

import asyncio  
  
balance = 0  
  
async def change_it_without_lock(n):  
  
    global balance  
  
    balance = balance + n  
    await asyncio.sleep(1)  
    balance = balance - n  
  
    print(balance)  
  
  
loop = asyncio.get_event_loop()  
  
res = loop.run_until_complete(asyncio.gather(change_it_without_lock(10), change_it_without_lock(8),  
                   change_it_without_lock(2), change_it_without_lock(7)))  
  
print(balance)

逻辑有了些许批改,当我对全局变量 balance 进行加法运算后,被动开释使用权,让别的协程运行,随后立即切换回来,再进行减法运算,如此往返,同时开启四个协程工作,让咱们来看一下代码运行后果:

17  
9  
7  
0  
0  
liuyue:mytornado liuyue$

能够看到,协程运行过程中,并没有保障“状态统一”,也就是一旦通过 await 关键字切换协程,变量的状态并不会进行同步,从而导致执行过程中变量状态的“混乱状态”,然而所有协程执行结束后,变量 balance 的最终后果是 0,意味着协程操作变量的最终一致性是能够保障的。

为了比照,咱们再用多线程试一下同样的逻辑:

import threading  
import time  
  
balance = 0  
  
def change_it_without_lock(n):  
    global balance  
  
    for i in range(1000000):  
        balance = balance + n  
        balance = balance - n  
  
    print(balance)  
  
  
threads = [threading.Thread(target=change_it_without_lock, args=(8,)),  
    threading.Thread(target=change_it_without_lock, args=(10,)),  
    threading.Thread(target=change_it_without_lock, args=(10,)),  
    threading.Thread(target=change_it_without_lock, args=(8,))  
]  
  
[t.start() for t in threads]  
[t.join() for t in threads]  
  
print(balance)

多线程逻辑执行后果:

liuyue:mytornado liuyue$ python3 "/Users/liuyue/wodfan/work/mytornado/test.py"  
28  
18  
10  
0  
8

能够看到,多线程在未加锁的状况下,连最终一致性也无奈保障,因为线程是零碎态切换,尽管同时只能有一个线程执行,但切换过程是争抢的,也就会导致写操作被原子性笼罩,而协程尽管在手动切换过程中也无奈保障状态统一,然而能够保障最终一致性呢?因为协程是用户态,切换过程是合作的,所以写操作不会被争抢笼罩,会被程序执行,所以必定能够保障最终一致性。

协程在工作状态中,被动切换了使用权,而咱们又想在执行过程中保障共享数据的强一致性,该怎么办?毫无疑问,还是只能加锁:

import asyncio  
  
balance = 0  
  
async def change_it_with_lock(n):  
  
    async with lock:  
  
        global balance  
  
        balance = balance + n  
        await asyncio.sleep(1)  
        balance = balance - n  
  
        print(balance)  
  
  
lock = asyncio.Lock()  
  
  
loop = asyncio.get_event_loop()  
  
res = loop.run_until_complete(asyncio.gather(change_it_with_lock(10), change_it_with_lock(8),  
                   change_it_with_lock(2), change_it_with_lock(7)))  
  
print(balance)

协程加锁执行后后果:

liuyue:mytornado liuyue$ python3 "/Users/liuyue/wodfan/work/mytornado/test.py"  
0  
0  
0  
0  
0

是的,无论是后果,还是过程中,都放弃了其一致性,然而咱们也付出了相应的代价,那就是工作又回到了线性同步执行,再也没有异步的加持了。话说回来,世界上的事件原本就是这样,原本就没有两败俱伤的解决方案,又要共享状态,又想多协程,还想变量平安,这可能吗?

协程是否须要加锁

论断当然就是看应用场景,如果协程在操作共享变量的过程中,没有被动放弃执行权 (await),也就是没有切换挂起状态,那就不须要加锁,执行过程自身就是平安的;可是如果在执行事务逻辑块中被动放弃执行权了,会分两种状况,如果在逻辑执行过程中咱们须要判断变量状态,或者执行过程中要依据变量状态进行一些上游操作,则必须加锁,如果咱们不关注执行过程中的状态,只关注最终后果一致性,则不须要加锁。是的,抛开剂量谈毒性,是不主观的,给一个衰弱的人注射吗啡是立功,然而给一个垂死的人注射吗啡,那就是最大的道德,所以说,道德不是空洞的,脱离对象孤立存在的,同理,抛开场景谈逻辑,也是不主观的,协程也不是虚空的,脱离具体场景孤立存在的,咱们应该养成具体问题具体分析的辩证唯物思维,只有把握了辩证的矛盾思维能力更全面更灵便的对待问题,能力透过景象,把握实质。

原文转载自「刘悦的技术博客」https://v3u.cn/a_id_208

正文完
 0