并发异步编程之争:协程(asyncio)到底需不需要加锁?(线程/协程安全/挂起/主动切换)Python3

简介: 协程与线程向来焦孟不离,但事实上是,线程更被我们所熟知,在Python编程领域,单核同时间内只能有一个线程运行,这并不是什么缺陷,这实际上是符合客观逻辑的,单核处理器本来就没法同时处理两件事情,要同时进行多件事情本来就需要正在运行的让出处理器,然后才能去处理另一件事情,左手画方右手画圆在现实中本来就不成立,只不过这个让出的过程是线程调度器主动抢占的。

协程与线程向来焦孟不离,但事实上是,线程更被我们所熟知,在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),也就是没有切换挂起状态,那就不需要加锁,执行过程本身就是安全的;可是如果在执行事务逻辑块中主动放弃执行权了,会分两种情况,如果在逻辑执行过程中我们需要判断变量状态,或者执行过程中要根据变量状态进行一些下游操作,则必须加锁,如果我们不关注执行过程中的状态,只关注最终结果一致性,则不需要加锁。

是的,抛开剂量谈毒性,是不客观的,给一个健康的人注射吗啡是犯罪,但是给一个垂死的人注射吗啡,那就是最大的道德,所以说,道德不是空泛的,脱离对象孤立存在的,同理,抛开场景谈逻辑,也是不客观的,协程也不是虚空的,脱离具体场景孤立存在的,我们应该养成具体问题具体分析的辩证唯物思想,只有掌握了辩证的矛盾思维才能更全面更灵活的看待问题,才能透过现象,把握本质。

相关文章
|
8月前
|
数据采集 Go API
Go语言实战案例:多协程并发下载网页内容
本文是《Go语言100个实战案例 · 网络与并发篇》第6篇,讲解如何使用 Goroutine 和 Channel 实现多协程并发抓取网页内容,提升网络请求效率。通过实战掌握高并发编程技巧,构建爬虫、内容聚合器等工具,涵盖 WaitGroup、超时控制、错误处理等核心知识点。
|
11月前
|
人工智能 安全 IDE
Python 的类型安全是如何实现的?
本文探讨了 Python 的类型安全实现方式。从 3.5 版本起,Python 引入类型提示(Type Hints),结合静态检查工具(如 mypy)和运行时验证库(如 pydantic),增强类型安全性。类型提示仅用于开发阶段的静态分析,不影响运行时行为,支持渐进式类型化,保留动态语言灵活性。泛型机制进一步提升通用代码的类型安全性。总结而言,Python 的类型系统是动态且可选的,兼顾灵活性与安全性,符合“显式优于隐式”的设计哲学。
250 2
|
安全 API C语言
Python程序的安全逆向(关于我的OPENAI的APIkey是如何被盗的)
本文介绍了如何使用C语言编写一个简单的文件加解密程序,并讨论了如何为编译后的软件添加图标。此外,文章还探讨了Python的.pyc、.pyd等文件的原理,以及如何生成和使用.pyd文件来增强代码的安全性。通过视频和教程,作者详细讲解了生成.pyd文件的过程,并分享了逆向分析.pyd文件的方法。最后,文章提到可以通过定制Python解释器来进一步保护源代码。
439 6
|
开发框架 Java .NET
.net core 非阻塞的异步编程 及 线程调度过程
【11月更文挑战第12天】本文介绍了.NET Core中的非阻塞异步编程,包括其基本概念、实现方式及应用示例。通过`async`和`await`关键字,程序可在等待I/O操作时保持线程不被阻塞,提高性能。文章还详细说明了异步方法的基础示例、线程调度过程、延续任务机制、同步上下文的作用以及如何使用`Task.WhenAll`和`Task.WhenAny`处理多个异步任务的并发执行。
395 1
|
安全 Go 调度
探索Go语言的并发模式:协程与通道的协同作用
Go语言以其并发能力闻名于世,而协程(goroutine)和通道(channel)是实现并发的两大利器。本文将深入了解Go语言中协程的轻量级特性,探讨如何利用通道进行协程间的安全通信,并通过实际案例演示如何将这两者结合起来,构建高效且可靠的并发系统。
|
Java Android开发 UED
🧠Android多线程与异步编程实战!告别卡顿,让应用响应如丝般顺滑!🧵
在Android开发中,为应对复杂应用场景和繁重计算任务,多线程与异步编程成为保证UI流畅性的关键。本文将介绍Android中的多线程基础,包括Thread、Handler、Looper、AsyncTask及ExecutorService等,并通过示例代码展示其实用性。AsyncTask适用于简单后台操作,而ExecutorService则能更好地管理复杂并发任务。合理运用这些技术,可显著提升应用性能和用户体验,避免内存泄漏和线程安全问题,确保UI更新顺畅。
549 5
|
存储 安全 数据安全/隐私保护
安全升级!Python AES加密实战,为你的代码加上一层神秘保护罩
【9月更文挑战第12天】在软件开发中,数据安全至关重要。本文将深入探讨如何使用Python中的AES加密技术保护代码免受非法访问和篡改。AES(高级加密标准)因其高效性和灵活性,已成为全球最广泛使用的对称加密算法之一。通过实战演练,我们将展示如何利用pycryptodome库实现AES加密,包括生成密钥、初始化向量(IV)、加密和解密文本数据等步骤。此外,还将介绍密钥管理和IV随机性等安全注意事项。通过本文的学习,你将掌握使用AES加密保护敏感数据的方法,为代码增添坚实的安全屏障。
726 8
|
存储 安全 算法
RSA在手,安全我有!Python加密解密技术,让你的数据密码坚不可摧
【9月更文挑战第11天】在数字化时代,信息安全至关重要。传统的加密方法已难以应对日益复杂的网络攻击。RSA加密算法凭借其强大的安全性和广泛的应用场景,成为保护敏感数据的首选。本文介绍RSA的基本原理及在Python中的实现方法,并探讨其优势与挑战。通过使用PyCryptodome库,我们展示了RSA加密解密的完整流程,帮助读者理解如何利用RSA为数据提供安全保障。
529 5
|
存储 安全 算法
显微镜下的安全战!Python加密解密技术,透视数字世界的每一个安全细节
【9月更文挑战第7天】在数字世界中,数据安全至关重要。Python加密解密技术如同显微镜下的精密工具,确保信息的私密性和完整性。以大型医疗机构为例,通过AES和RSA算法的结合,既能高效加密大量医疗数据,又能安全传输密钥,防止数据泄露。以下是使用Python的`pycryptodome`库实现AES加密和RSA密钥交换的简化示例。此方案不仅提高了数据安全性,还为数字世界的每个细节提供了坚实保障,引领我们迈向更安全的未来。
218 2
|
数据采集 消息中间件 并行计算
进程、线程与协程:并发执行的三种重要概念与应用
进程、线程与协程:并发执行的三种重要概念与应用
522 0

推荐镜像

更多