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

本文主要是介绍并发异步编程之争:协程(asyncio)到底需不需要加锁?(线程/协程安全/挂起/主动切换)Python3,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

原文转载自「刘悦的技术博客」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

这篇关于并发异步编程之争:协程(asyncio)到底需不需要加锁?(线程/协程安全/挂起/主动切换)Python3的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/455923

相关文章

Linux 网络编程 --- 应用层

一、自定义协议和序列化反序列化 代码: 序列化反序列化实现网络版本计算器 二、HTTP协议 1、谈两个简单的预备知识 https://www.baidu.com/ --- 域名 --- 域名解析 --- IP地址 http的端口号为80端口,https的端口号为443 url为统一资源定位符。CSDNhttps://mp.csdn.net/mp_blog/creation/editor

【Python编程】Linux创建虚拟环境并配置与notebook相连接

1.创建 使用 venv 创建虚拟环境。例如,在当前目录下创建一个名为 myenv 的虚拟环境: python3 -m venv myenv 2.激活 激活虚拟环境使其成为当前终端会话的活动环境。运行: source myenv/bin/activate 3.与notebook连接 在虚拟环境中,使用 pip 安装 Jupyter 和 ipykernel: pip instal

客户案例:安全海外中继助力知名家电企业化解海外通邮困境

1、客户背景 广东格兰仕集团有限公司(以下简称“格兰仕”),成立于1978年,是中国家电行业的领军企业之一。作为全球最大的微波炉生产基地,格兰仕拥有多项国际领先的家电制造技术,连续多年位列中国家电出口前列。格兰仕不仅注重业务的全球拓展,更重视业务流程的高效与顺畅,以确保在国际舞台上的竞争力。 2、需求痛点 随着格兰仕全球化战略的深入实施,其海外业务快速增长,电子邮件成为了关键的沟通工具。

安全管理体系化的智慧油站开源了。

AI视频监控平台简介 AI视频监控平台是一款功能强大且简单易用的实时算法视频监控系统。它的愿景是最底层打通各大芯片厂商相互间的壁垒,省去繁琐重复的适配流程,实现芯片、算法、应用的全流程组合,从而大大减少企业级应用约95%的开发成本。用户只需在界面上进行简单的操作,就可以实现全视频的接入及布控。摄像头管理模块用于多种终端设备、智能设备的接入及管理。平台支持包括摄像头等终端感知设备接入,为整个平台提

高并发环境中保持幂等性

在高并发环境中保持幂等性是一项重要的挑战。幂等性指的是无论操作执行多少次,其效果都是相同的。确保操作的幂等性可以避免重复执行带来的副作用。以下是一些保持幂等性的常用方法: 唯一标识符: 请求唯一标识:在每次请求中引入唯一标识符(如 UUID 或者生成的唯一 ID),在处理请求时,系统可以检查这个标识符是否已经处理过,如果是,则忽略重复请求。幂等键(Idempotency Key):客户端在每次

2024网安周今日开幕,亚信安全亮相30城

2024年国家网络安全宣传周今天在广州拉开帷幕。今年网安周继续以“网络安全为人民,网络安全靠人民”为主题。2024年国家网络安全宣传周涵盖了1场开幕式、1场高峰论坛、5个重要活动、15场分论坛/座谈会/闭门会、6个主题日活动和网络安全“六进”活动。亚信安全出席2024年国家网络安全宣传周开幕式和主论坛,并将通过线下宣讲、创意科普、成果展示等多种形式,让广大民众看得懂、记得住安全知识,同时还

【编程底层思考】垃圾收集机制,GC算法,垃圾收集器类型概述

Java的垃圾收集(Garbage Collection,GC)机制是Java语言的一大特色,它负责自动管理内存的回收,释放不再使用的对象所占用的内存。以下是对Java垃圾收集机制的详细介绍: 一、垃圾收集机制概述: 对象存活判断:垃圾收集器定期检查堆内存中的对象,判断哪些对象是“垃圾”,即不再被任何引用链直接或间接引用的对象。内存回收:将判断为垃圾的对象占用的内存进行回收,以便重新使用。

Go Playground 在线编程环境

For all examples in this and the next chapter, we will use Go Playground. Go Playground represents a web service that can run programs written in Go. It can be opened in a web browser using the follow

深入理解RxJava:响应式编程的现代方式

在当今的软件开发世界中,异步编程和事件驱动的架构变得越来越重要。RxJava,作为响应式编程(Reactive Programming)的一个流行库,为Java和Android开发者提供了一种强大的方式来处理异步任务和事件流。本文将深入探讨RxJava的核心概念、优势以及如何在实际项目中应用它。 文章目录 💯 什么是RxJava?💯 响应式编程的优势💯 RxJava的核心概念

函数式编程思想

我们经常会用到各种各样的编程思想,例如面向过程、面向对象。不过笔者在该博客简单介绍一下函数式编程思想. 如果对函数式编程思想进行概括,就是f(x) = na(x) , y=uf(x)…至于其他的编程思想,可能是y=a(x)+b(x)+c(x)…,也有可能是y=f(x)=f(x)/a + f(x)/b+f(x)/c… 面向过程的指令式编程 面向过程,简单理解就是y=a(x)+b(x)+c(x)