Chapter11_同步
1、同步原语,并发原语的概念理解
这里提到的“同步原语”和“并发原语”在计算机科学中是指一些基本的编程构件,它们被用于在多线程或多进程环境中协调不同线程或进程之间的操作,以防止竞争条件和数据不一致的问题。
同步原语(Synchronization Primitives)
同步原语是用于协调不同线程或进程的工具,确保它们以安全的方式访问共享资源。常见的同步原语包括:
- 互斥锁(Mutex):用于确保同一时刻只有一个线程可以访问某个共享资源。它可以防止多个线程同时修改数据,从而避免数据竞争。
- 信号量(Semaphore):用于控制对共享资源的访问次数。信号量可以允许多个线程同时访问,但数量是有限制的。
- 条件变量(Condition Variable):用于使线程等待某个条件发生。条件变量通常与互斥锁一起使用,使线程在等待条件时能够释放锁。
- 读写锁(Read-Write Lock):允许多个线程同时读共享资源,但在写资源时,只允许一个线程访问。这种锁适用于读多写少的场景。
并发原语(Concurrency Primitives)
并发原语包括同步原语,但也可以指更广泛的用于实现并发控制的构件,如:
- 线程(Thread)和进程(Process):基本的并发执行单位。线程是轻量级的执行单元,共享同一进程内的资源;进程则是独立的执行单元,有自己独立的内存空间。
- 协程(Coroutine):一种轻量级的并发执行方式,适用于异步编程模型,比如
asyncio中的协程。协程在单线程中实现并发,避免了多线程和多进程的开销。 - 任务队列(Task Queue):用于调度和执行并发任务的队列。任务队列可以管理大量的任务,并确保它们被有效地执行。
- 事件循环(Event Loop):用于管理和调度异步任务的循环机制。它是异步编程(如
asyncio)的核心,确保异步任务在适当的时间点被执行。
上述文本中的重点
文本提到,在使用 asyncio 时,尽管其单线程特性消除了多线程或多进程应用程序中某些并发错误,但并没有完全消除这些问题。在某些情况下,仍然需要使用同步原语来防止单线程并发模型中特有的错误。
此外,同步原语不仅能防止并发错误,还有其他用途。例如:
- 可以根据与供应商的合同同时发出多个请求。
- 存在多个 worker 需要在新数据可用时得到通知的情况。
最后,文本指出将在文章中通过示例学习如何在 asyncio 代码中引入竞争条件,并学习使用锁和其他并发原语来解决它们,以及控制对共享资源(如数据库连接池)的访问。
2、原子操作和非原子操作,python举例说明
在计算机科学中,原子操作是指在执行过程中不会被其他线程中断的操作。这意味着原子操作要么完全执行,要么完全不执行,不存在部分执行的状态。非原子操作则是指在执行过程中可以被其他线程中断的操作,这可能导致数据不一致或竞态条件。
在Python中,原子操作和非原子操作通常与多线程编程有关。Python的全局解释器锁(Global Interpreter Lock,GIL)确保了同一时刻只有一个线程可以执行Python字节码,这在一定程度上简化了原子操作的实现。但是,当涉及到I/O操作或使用某些库时,原子性可能就不再保证。
以下是一些Python中原子操作和非原子操作的例子:
原子操作的例子:
-
基本赋值操作:
x = 5 # 这是原子操作,因为赋值操作要么完全完成,要么完全不执行。
2.使用threading.Lock:
import threading lock = threading.Lock() with lock: # 任何需要保护的代码块 pass # 这里可以放置原子操作
使用with语句可以确保在执行代码块时,其他线程不能进入这个区域,从而保证了操作的原子性。
非原子操作的例子:
-
复合赋值操作(在没有锁的情况下):
x = 5 y = x + 1 # 这不是原子操作,因为读取x的值和写入y的值可以被其他线程中断。
2.共享资源的修改(没有使用锁):
shared_resource = 0 def update_resource(): global shared_resource temp = shared_resource # 读取操作 temp += 1 # 计算操作 shared_resource = temp # 写入操作
# 如果多个线程同时执行update_resource,那么读取、计算和写入操作之间可能发生中断, # 导致竞态条件和数据不一致。
3. I/O操作(如文件读写)
with open('file.txt', 'w') as f: f.write('Hello, World!') # 文件写入操作通常不是原子的,尤其是在多线程环境下。
在多线程环境中,为了保证操作的原子性,通常需要使用锁或其他同步机制来确保在执行关键代码段时,不会有其他线程干扰。
Python提供了多种同步原语,如Lock, RLock, Semaphore等,来帮助开发者编写线程安全的代码。
3、为什么第一段代码不会报错,而第二段代码却会报错。请详细解释原因 【一下理解不了,反复多去理解】
# example11_1
import asyncio counter: int = 0 async def increment(): global counter await asyncio.sleep(0.01) counter = counter + 1 async def main(): global counter for _ in range(1000): tasks = [asyncio.create_task(increment()) for _ in range(100)] await asyncio.gather(*tasks) print(f'Counter is {counter}') assert counter == 100 counter = 0 asyncio.run(main())
# example11_2
import asyncio counter: int = 0 async def increment(): global counter temp_counter = counter temp_counter = temp_counter + 1 await asyncio.sleep(0.01) counter = temp_counter async def main(): global counter for _ in range(1000): tasks = [asyncio.create_task(increment()) for _ in range(100)] await asyncio.gather(*tasks) print(f'Counter is {counter}') assert counter == 100 counter = 0 asyncio.run(main())
这两段代码都涉及到了Python的异步编程和全局变量的使用。它们的主要区别在于如何更新全局变量counter。
example11_1 代码分析:
在这段代码中,increment函数通过global counter声明了对全局变量counter的引用。这意味着函数内部对counter的修改将影响全局作用域中的counter变量。
async def increment(): global counter await asyncio.sleep(0.01) counter = counter + 1
这里,counter = counter + 1是一个复合赋值操作,但它在Python中实际上是一个原子操作。Python的解释器确保了赋值操作的原子性,即使在多线程环境中也是如此。
因此,即使多个协程几乎同时执行这个操作,每个协程都会看到counter的最新值,并正确地将其增加1。
example11_2 代码分析:
在这段代码中,increment函数的实现方式略有不同:
async def increment(): global counter temp_counter = counter temp_counter = temp_counter + 1 await asyncio.sleep(0.01) counter = temp_counter
这里,counter的值首先被赋值给局部变量temp_counter,然后temp_counter被增加1。
问题出现在await asyncio.sleep(0.01)这一行。
由于await会暂停当前协程的执行,允许其他协程运行,所以在await之后,counter的值可能已经被其他协程修改了。
这意味着,当await执行完毕后,temp_counter变量持有的值可能已经过时,不再是counter的最新值。
因此,将temp_counter赋值回counter时,可能会丢失在await期间其他协程对counter所做的更新。
为什么会报错?
在example11_2中,由于await导致的协程挂起,使得在await之后counter的值可能已经改变,这破坏了temp_counter与counter的同步。
因此,当100个协程同时运行时,它们可能会多次更新counter,但由于await的存在,这些更新可能不会被正确地累加到counter中。
最终,counter的值可能不等于100,这违反了assert counter == 100的断言,导致程序抛出AssertionError。
总结来说,example11_1中的代码能够正确工作,因为Python的赋值操作是原子的,
而example11_2中的代码会报错,因为await导致了协程执行的中断,使得局部变量temp_counter与全局变量counter失去了同步。
4、如下两端代码的差异,为什么第一段代码报错,第二段代码却不报错?
#第一段代码 import asyncio class MockSocket: def __init__(self): self.socket_closed = True async def send(self, msg: str): if self.socket_closed: raise Exception('Socket is closed!') print(f'Sending: {msg}') await asyncio.sleep(1) print(f'Sent: {msg}') def close(self): self.socket_closed = True user_names_to_sockets = {'John': MockSocket(), 'Terry': MockSocket(), 'Graham': MockSocket(), 'Eric': MockSocket()} async def user_disconnect(username: str): print(f'{username} disconnected!') socket = user_names_to_sockets.pop(username) socket.close() async def message_all_users(): print('Creating message tasks') messages = [socket.send(f'Hello {user}') for user, socket in user_names_to_sockets.items()] await asyncio.gather(*messages) async def main(): await asyncio.gather(message_all_users(), user_disconnect('Eric')) asyncio.run(main())
两段代码的主要区别在于对共享资源user_names_to_sockets的访问方式。第一段代码在访问和修改共享资源时没有使用锁,而第二段代码使用了asyncio.Lock来同步对共享资源的访问。
第一段代码(不使用锁):
在这段代码中,message_all_users函数和user_disconnect函数都尝试同时访问和修改user_names_to_sockets字典。
async def message_all_users(): print('Creating message tasks') messages = [socket.send(f'Hello {user}') for user, socket in user_names_to_sockets.items()] await asyncio.gather(*messages)
这个函数尝试发送消息给所有用户,而在这个过程中,它遍历user_names_to_sockets字典。
async def user_disconnect(username: str): print(f'{username} disconnected!') socket = user_names_to_sockets.pop(username) socket.close()
user_disconnect函数尝试从字典中移除一个用户,这涉及到修改共享资源。
当两个协程(或多个协程)同时运行时,如果它们尝试同时修改同一个数据结构(在这个例子中是user_names_to_sockets字典),就可能发生竞态条件(race condition)。
竞态条件可能导致数据不一致,比如在字典中删除一个元素后,另一个协程可能仍然尝试使用这个已经被删除的元素。
# 第二段代码 import asyncio from asyncio import Lock class MockSocket: def __init__(self): self.socket_closed = False async def send(self, msg: str): if self.socket_closed: raise Exception('Socket is closed!') print(f'Sending: {msg}') await asyncio.sleep(1) print(f'Sent: {msg}') def close(self): self.socket_closed = True user_names_to_sockets = {'John': MockSocket(), 'Terry': MockSocket(), 'Graham': MockSocket(), 'Eric': MockSocket()} async def user_disconnect(username: str, user_lock: Lock): print(f'{username} disconnected!') async with user_lock: # A print(f'Removing {username} from dictionary') socket = user_names_to_sockets.pop(username) socket.close() async def message_all_users(user_lock: Lock): print('Creating message tasks') async with user_lock: # B messages = [socket.send(f'Hello {user}') for user, socket in user_names_to_sockets.items()] await asyncio.gather(*messages) async def main(): user_lock = Lock() await asyncio.gather(message_all_users(user_lock), user_disconnect('Eric', user_lock)) asyncio.run(main())
第二段代码(使用锁):
在第二段代码中,引入了一个asyncio.Lock对象user_lock,并在访问和修改共享资源之前获取这个锁。
async def user_disconnect(username: str, user_lock: Lock): print(f'{username} disconnected!') async with user_lock: # A print(f'Removing {username} from dictionary') socket = user_names_to_sockets.pop(username) socket.close()
在user_disconnect函数中,通过async with user_lock:语句,确保在执行删除操作之前,没有其他协程可以访问user_names_to_sockets字典。
async def message_all_users(user_lock: Lock): print('Creating message tasks') async with user_lock: # B messages = [socket.send(f'Hello {user}') for user, socket in user_names_to_sockets.items()] await asyncio.gather(*messages)
同样,在message_all_users函数中,通过async with user_lock:语句,确保在发送消息之前,没有其他协程可以修改user_names_to_sockets字典。
通过使用锁,第二段代码确保了对共享资源的访问是互斥的,即在任何时刻,只有一个协程可以访问和修改user_names_to_sockets字典。这避免了竞态条件,确保了数据的一致性,因此不会报错。
总结:
第一段代码会报错,因为它没有处理多个协程同时访问和修改共享资源时可能出现的竞态条件。
而第二段代码通过使用asyncio.Lock来同步对共享资源的访问,避免了竞态条件,因此不会报错。
简而言之,锁是解决并发编程中竞态条件问题的一种常见方法。
5、针对4的进一步解释,要求举例具体说明。
之前的解释有误。实际上,在message_all_users函数中使用锁并不是为了阻止其他协程修改user_names_to_sockets字典,而是为了确保在发送消息时,不会有其他协程同时修改这个字典。
让我们通过一个具体的例子来说明为什么需要使用锁:
假设我们有两个协程:message_all_users和user_disconnect。在没有锁的情况下,它们可能会同时运行:
message_all_users协程开始发送消息给所有用户,它遍历user_names_to_sockets字典并创建发送任务。- 同时,
user_disconnect协程被触发,它尝试从字典中移除一个用户(例如,Eric)。
如果message_all_users在遍历字典时user_disconnect也尝试修改这个字典(通过删除一个条目),就会发生竞态条件。这可能导致以下几种问题:
message_all_users可能会尝试向已经被删除的用户发送消息。user_disconnect可能会在message_all_users正在遍历字典时删除条目,导致遍历过程中的索引错误。
现在,让我们看看在第二段代码中如何通过使用锁来解决这个问题:
async def message_all_users(user_lock: Lock): print('Creating message tasks') async with user_lock: # B messages = [socket.send(f'Hello {user}') for user, socket in user_names_to_sockets.items()] await asyncio.gather(*messages)
在这个版本中,message_all_users在创建消息任务之前首先获取锁。
这意味着在async with user_lock:块执行期间,没有其他协程能够获取这个锁,因此也就不能修改user_names_to_sockets字典。
同时,user_disconnect函数也尝试获取同一个锁:
async def user_disconnect(username: str, user_lock: Lock): print(f'{username} disconnected!') async with user_lock: # A print(f'Removing {username} from dictionary') socket = user_names_to_sockets.pop(username) socket.close()
当user_disconnect尝试获取锁时,如果message_all_users已经持有锁,user_disconnect将等待直到锁被释放。
这样,我们可以确保在message_all_users发送消息的过程中,user_names_to_sockets不会被修改,从而避免了竞态条件。
总结一下,锁在这里的作用是同步对共享资源user_names_to_sockets的访问,确保在任何时刻只有一个协程可以执行修改这个字典的操作。
这样可以防止多个协程同时修改同一个数据结构时可能出现的问题。
5、如下这三个算法,如何理解。 [from page309, 后面深入理解]

流量重塑算法、漏桶算法、令牌桶算法概念解释:
-
流量重塑算法(Traffic Shaping Algorithms):这些算法用于控制网络流量的速率,以防止网络拥塞和过载。它们可以限制在特定时间内可以发送的数据量。
-
漏桶算法(Leaky Bucket Algorithm):这个算法通过以固定速率“漏出”数据来平滑数据流量的突发。即使流量突发,漏桶也会以均匀的速率释放数据,从而防止下游网络或服务器过载。
-
令牌桶算法(Token Bucket Algorithm):这个算法通过允许数据以最大突发大小传输,然后以固定速率恢复,来控制数据的传输速率。令牌桶可以存储一定数量的令牌,每个发送的数据包消耗一个令牌。如果没有令牌可用,数据包将被延迟发送。
以下几个算法经实测都在本地能够跑通。
- TrafficShaping算法
import asyncio from asyncio import Semaphore import time async def task(semaphore): async with semaphore: await asyncio.sleep(2) # 模拟任务执行时间 print(f"Task executed at {time.time():.2f}") async def main(rate): semaphore = Semaphore(rate) # 设置信号量的数量 tasks = [asyncio.create_task(task(semaphore)) for _ in range(100)] await asyncio.gather(*tasks) asyncio.run(main(rate=5)) # 限制最多同时执行5个任务
- LeakeyBucky算法
import asyncio import time class LeakyBucket: def __init__(self, rate): self.rate = rate # 每秒钟可以处理的请求数 self.tokens = 0 self.last_checked = time.time() async def add_token(self): while True: now = time.time() self.tokens = min(self.rate, self.tokens + (now - self.last_checked)) self.last_checked = now if self.tokens >= 1: self.tokens -= 1 yield else: await asyncio.sleep(0.01) # 等待令牌生成 async def execute_task(self, task): async for _ in self.add_token(): await task() async def task(): print(f"Task executed at {time.time():.2f}") async def main(): bucket = LeakyBucket(rate=5) # 每秒生成5个令牌 tasks = [asyncio.create_task(bucket.execute_task(task)) for _ in range(20)] await asyncio.gather(*tasks) asyncio.run(main())
- TokenBucket算法
import asyncio class TokenBucket: def __init__(self, capacity, tokens_per_second): self.capacity = capacity self.tokens_per_second = tokens_per_second self.tokens = capacity self.last_refill = asyncio.get_running_loop().time() def refill(self): now = asyncio.get_running_loop().time() elapsed = now - self.last_refill self.tokens = min(self.capacity, int(self.tokens + elapsed * self.tokens_per_second)) self.last_refill = now async def consume(self): self.refill() if self.tokens >= 1: self.tokens -= 1 return True return False async def handle_request(bucket): while True: if await bucket.consume(): print(f"Request handled at {asyncio.get_running_loop().time():.2f}") await asyncio.sleep(0.15) # Simulate some work else: print("Request delayed due to lack of tokens.") await asyncio.sleep(1) # Wait before trying again async def main(): bucket = TokenBucket(capacity=10, tokens_per_second=5) request_handler = asyncio.create_task(handle_request(bucket)) # Run the request handler for a short period to demonstrate the behavior await asyncio.sleep(5) request_handler.cancel() asyncio.run(main())
6、追问:对于上述LeakyBucket算法的部分代码,我还是不太理解。
- 1. 为什么 self.tokens要取min,另外,self.tokens是令牌数(整数),而now-self.last_checked是一段时间,这两个相加有什么意义吗?为什么要这样做?
在这段代码中,self.rate 代表桶每秒可以增加的令牌数。然而,我之前的解释中提到的乘法操作实际上并没有在代码中体现,这是一个错误。正确的解释如下:
self.tokens + (now - self.last_checked) 这部分代码意图是增加自上次 self.last_checked 以来经过的时间(now - self.last_checked)所对应的令牌数。
但这里直接相加是不正确的,因为 now - self.last_checked 是一个时间差,并不是令牌数。正确的做法应该是将这个时间差乘以桶的填充速率 self.rate 来得到实际应该增加的令牌数。
正确的代码应该是:
token_increment = (now - self.last_checked) * self.rate # 计算应该增加的令牌数 self.tokens = min(self.capacity, self.tokens + token_increment) # 确保不超过桶的容量
取 min 的原因:min(self.rate, ...) 确保即使计算出的 token_increment 超出了桶的容量,self.tokens 也不会超过 self.rate,因为桶的大小是固定的。
- 2. else:分支语句中 为什么await.sleep(0.01)就能够等待令牌生成,我并没有看到有self.tokens+=1这种类似语句呀,那这些令牌是怎么生成的?
是的,代码中没有显式的 self.tokens += 1 语句。令牌的生成是在 self.tokens = min(self.rate, self.tokens + (now - self.last_checked)) 这一行完成的。
当 self.tokens 小于 1 时,代码就会进入 else 分支,通过 await asyncio.sleep(0.01) 短暂地让出 CPU 控制权,然后再次检查令牌数量。由于时间已经过去了一小段时间,所以 now - self.last_checked 的值会增大,从而导致 self.tokens 的值也会增加。
换句话说,这里使用了一种"轮询"的方式,通过不断检查当前时间,根据时间的推移来动态计算应该生成多少令牌。
- 3. yield是什么时候暂停,什么时候启动的,时机举例详细说下。
yield 的暂停和启动时机:
暂停:当 yield 被执行时,生成器函数 add_token 会暂停执行,并保存当前的执行状态。
启动:当 execute_task 中的 async for 循环请求 add_token 的下一个值时,add_token 函数会从上次 yield 暂停的地方恢复执行。
7、追问,对于TokenBucket算法,为什么不用time.time(), 而要用asyncio.get_running_loop().time()?这两者有何异同
asyncio.get_running_loop().time() 用于在异步环境中获取当前时间。
在异步环境下,使用 time.time() 可能会导致不准确的结果,因为 time.time() 是基于系统时钟的,而异步环境中可能存在多个事件循环。
asyncio.get_running_loop().time() 则是基于当前事件循环的内部时钟,可以确保获取的时间戳与事件循环的时序一致。
time.time() 和 asyncio.get_running_loop().time() 都用于获取当前时间,但它们属于不同的模块,并且用途上有一些差异。
time.time()
- 来源:
time.time()函数来自 Python 标准库中的time模块。 - 功能:返回当前时间的时间戳,单位为秒,从纪元(Epoch,即1970年1月1日00:00:00 UTC)开始计算。
- 用途:广泛用于需要时间戳的场合,如记录时间、计算时间差、设置过期时间等。
- 线程安全:在多线程环境中,
time.time()是线程安全的,可以被多个线程同时调用。
asyncio.get_running_loop().time()
- 来源:
asyncio.get_running_loop().time()来自 Python 的asyncio模块,它是异步编程的核心模块。 - 功能:返回一个浮点数表示的当前时间,单位同样是秒,从纪元开始计算。
- 用途:主要用于异步编程中,获取当前时间以用于计时、计算时间间隔等。由于
asyncio是单线程的事件循环,所以它的时间函数通常用于同步异步任务的执行时间。 - 线程安全:
asyncio的事件循环是设计为在单个线程内运行的,所以asyncio.get_running_loop().time()并不需要像多线程环境中那样考虑线程安全问题。
异同点:
- 相同点:两者都提供了从纪元开始的当前时间戳,单位都是秒。
- 不同点:
- 模块:
time.time()属于time模块,而asyncio.get_running_loop().time()属于asyncio模块。 - 上下文:
time.time()可以在任何Python程序中使用,而asyncio.get_running_loop().time()主要用于异步编程环境。 - 精度:
asyncio.get_running_loop().time()可能提供了更高的时间精度,因为它是为事件循环设计的,而time.time()的精度可能受到系统时间和time模块实现的限制。 - 线程模型:
time.time()考虑了多线程的线程安全,而asyncio.get_running_loop().time()则是为单线程的事件循环设计的。
- 模块:
在实际开发中,如果你正在编写异步代码,推荐使用 asyncio.get_running_loop().time(),因为它与 asyncio 模块的其他部分协同工作,可以提供更一致的行为。对于一般的同步代码,使用 time.time() 是合适的。
8、追问,对于5中的TokenBucket算法,如何理解它的输出结果。
Request handled at 91203.30 Request handled at 91203.45 Request handled at 91203.61 Request handled at 91203.76 Request handled at 91203.92 Request handled at 91204.08 Request handled at 91204.23 Request handled at 91204.39 Request handled at 91204.55 Request handled at 91204.70 Request delayed due to lack of tokens. Request handled at 91205.88 Request handled at 91206.03 Request handled at 91206.19 Request handled at 91206.34 Request handled at 91206.50 Request delayed due to lack of tokens. Request handled at 91207.66 Request handled at 91207.81 Request handled at 91207.97 Request handled at 91208.12 Request handled at 91208.28
该输出结果展示了令牌桶算法的工作过程。让我们逐步解释一下:
- 程序启动时,初始化了一个容量为 10,每秒产生 5 个令牌的令牌桶。
- 前 10 个请求都被成功处理,因为初始时令牌桶是满的。每个请求处理后都会休眠 0.15 秒,模拟处理请求所需的时间。
- 第 11 个请求由于没有足够的令牌而被延迟,输出 "Request delayed due to lack of tokens."。程序会等待 1 秒后再次尝试消费令牌。
- 在等待的 1 秒内,令牌桶补充了 5 个令牌(每秒产生 5 个)。所以第 11 个请求在 91205.88 时刻被处理。
- 接下来的 4 个请求(91206.03 - 91206.50)也被成功处理,因为这时令牌桶中有足够的令牌。
- 第 16 个请求再次由于缺乏令牌而被延迟,输出 "Request delayed due to lack of tokens."。程序再次等待 1 秒。
- 在等待的 1 秒内,令牌桶补充了 5 个令牌。所以第 16 个请求在 91207.66 时刻被处理。
- 最后的 4 个请求(91207.81 - 91208.28)也被成功处理,因为这时令牌桶中有足够的令牌。
- 在 5 秒后,
main函数取消了请求处理任务,程序退出。
总的来说,这个输出结果清晰地展示了令牌桶算法如何控制请求的速率。
初始时允许突发的请求,之后以稳定的速率(每秒 5 个请求)处理后续的请求。
当请求的速率超过了令牌产生的速率时,就会延迟处理部分请求,直到令牌桶中有足够的令牌。
9、为什么BoundedSemaphore(2) 如下代码也会报错? [from example11_9.py]
import asyncio from asyncio import BoundedSemaphore async def main(): semaphore = BoundedSemaphore(2) await semaphore.acquire() semaphore.release() semaphore.release() asyncio.run(main()) ^^^^^^^^^^^^^^^^raise ValueError('BoundedSemaphore released too many times') ValueError: BoundedSemaphore released too many times Process finished with exit code 1
BoundedSemaphore 是 asyncio 模块中的一个同步原语,它与 Semaphore 类似,但有一个最大值限制。
BoundedSemaphore 的构造函数接受一个参数,表示信号量的最大值,这个值通常与同时可以获取信号量的协程数量相对应。
在您提供的代码中:
async def main(): semaphore = BoundedSemaphore(2) await semaphore.acquire() # 获取一个信号量 semaphore.release() # 释放一个信号量 semaphore.release() # 尝试再次释放信号量
当调用 semaphore.release() 时,它会释放一个之前通过 semaphore.acquire() 获取的信号量。
如果已经通过 semaphore.acquire() 获取了一个信号量,那么第一次调用 semaphore.release() 是没有问题的,它会正确地释放这个信号量。
但是,当您紧接着再次调用 semaphore.release() 时,问题出现了:由于没有其他协程获取额外的信号量,这次释放操作没有与之对应的获取操作。
BoundedSemaphore 跟踪已释放但尚未被获取的信号量的计数。如果释放次数超过了获取次数,就会抛出 ValueError,
因为这意味着信号量的当前值会超过其最大值,这违反了 BoundedSemaphore 的设计原则。
错误信息 ValueError: BoundedSemaphore released too many times 明确指出了这个问题:BoundedSemaphore 被释放了太多次数,超出了允许的范围。
为了避免这种错误,您应该确保每次调用 semaphore.release() 时,都有一个对应的 semaphore.acquire() 调用。如果需要手动管理信号量的释放,可以使用计数或其他同步机制来确保不会过度释放。
此外,通常推荐使用 async with semaphore: 上下文管理器来自动管理信号量的获取和释放,这样可以减少出错的机会:
async def main(): semaphore = BoundedSemaphore(2) async with semaphore: # 执行需要信号量的代码 pass # 上下文管理器会自动释放信号量,无需手动调用 semaphore.release()
在这个改进的代码中,当退出 async with 语句块时,上下文管理器会自动调用 semaphore.release(),确保信号量被正确释放。
10、如何理解如下代码。【from example11_10.py】
import asyncio import functools from asyncio import Event def trigger_event(event: Event): event.set() async def do_work_on_event(event: Event): print('Waiting for event...') await event.wait() # A print('Performing work!') await asyncio.sleep(1) # B print('Finished work!') event.clear() # C async def main(): event = asyncio.Event() asyncio.get_running_loop().call_later(5.0, functools.partial(trigger_event, event)) # D await asyncio.gather(do_work_on_event(event), do_work_on_event(event)) asyncio.run(main())
这段代码演示了如何使用 asyncio.Event() 来同步多个协程的执行。
asyncio.Event() 是一个用于控制流的同步原语,类似于线程编程中的事件对象。以下是代码的详细解释:
1. trigger_event 函数
def trigger_event(event: Event): event.set()
这个函数接受一个 Event 对象作为参数,并调用该对象的 .set() 方法。
.set() 方法用于将事件标记为“设置”(即,触发事件),允许所有等待此事件的协程从 await event.wait() 调用中唤醒并继续执行。
2. do_work_on_event 协程
async def do_work_on_event(event: Event): print('Waiting for event...') await event.wait() # A print('Performing work!') await asyncio.sleep(1) # B print('Finished work!') event.clear() # C
这个异步函数执行以下操作:
- 打印 "Waiting for event...",表示它正在等待事件被触发。
- 使用
await event.wait()等待事件被触发。这是异步等待,如果事件当前未被设置,协程将在此暂停,直到事件被触发。 - 事件被触发后,打印 "Performing work!" 并模拟一些工作,通过
await asyncio.sleep(1)暂停1秒钟。这是工作模拟阶段。 - 工作完成后,打印 "Finished work!"。
- 调用
event.clear()清除事件。.clear()方法将事件标记为“清除”状态,这意味着如果此时有其他协程在等待此事件,它们将不会被唤醒。
3. main 函数
async def main(): event = asyncio.Event() asyncio.get_running_loop().call_later(5.0, functools.partial(trigger_event, event)) # D await asyncio.gather(do_work_on_event(event), do_work_on_event(event))
这是程序的入口点,执行以下操作:
- 创建一个新的
Event对象。 - 使用
asyncio.get_running_loop().call_later(5.0, functools.partial(trigger_event, event))安排在5秒后触发事件。call_later是事件循环的一个方法,它接受一个时间(秒)和要稍后调用的函数。这里,它将在5秒后调用trigger_event函数,该函数将事件设置为触发状态。 - 使用
await asyncio.gather(do_work_on_event(event), do_work_on_event(event))并发运行两个do_work_on_event协程。这两个协程将等待上面创建的事件被触发。
程序执行流程
main函数启动并创建一个事件对象。- 事件循环安排5秒后触发该事件。
- 两个
do_work_on_event协程开始执行并等待事件。 - 5秒后,
trigger_event函数被调用,事件被设置,两个协程从event.wait()处唤醒。 - 两个协程并发执行它们的工作,打印消息并清除事件。
- 一旦工作完成,程序结束。
这个示例展示了如何使用 asyncio.Event() 来控制多个协程的执行流程,以及如何使用 call_later 来安排稍后执行的操作。
11、event.clear() 这里说是将事件重置为False(未发生状态),以备下一次使用。 我想知道它到底是做什么的?有什么作用?如果没有它会有什么问题?举例说明。
event.clear()的作用是将事件对象重置为未发生状态,以便于下次使用该事件对象进行等待。
如果不调用clear()方法,会导致下一次使用该事件对象时无法正常等待。
让我们通过一个例子来说明为什么需要调用clear()方法:
import asyncio async def waiter(event): print("Waiting for event to be set...") await event.wait() print("Event was set, continuing execution.") async def main(): event = asyncio.Event() # 创建一个任务来等待事件 wait_task = asyncio.create_task(waiter(event)) # 设置事件 print("Setting the event") event.set() # 等待waiter任务完成 await wait_task # 不调用event.clear() # 再次等待事件,但是由于事件没有被重置,不会阻塞 print("Waiting for event again...") await event.wait() print("This line will be printed immediately") asyncio.run(main())
输出:
Waiting for event to be set... Setting the event Event was set, continuing execution. Waiting for event again... This line will be printed immediately
在上面的例子中, 我们首先创建一个事件对象, 并启动一个任务来等待该事件被设置。然后我们设置了事件,任务被唤醒并继续执行。
关键是,在第二次调用await event.wait()时,由于事件对象没有被重置为未发生状态,
因此不会阻塞,代码会立即执行print("This line will be printed immediately")。
如果我们在第一次等待后调用了event.clear()方法,
那么第二次调用await event.wait()时就会再次阻塞,直到事件被重新设置。
因此,event.clear()方法的作用就是将事件重置为未发生状态,
以便于下次使用同一个事件对象时能够正确地等待事件发生。如果不调用该方法,就可能导致无法正确地等待事件发生的情况。
总结一下完整的执行流程:
- 主程序同步输出
Setting the event waiter协程创建,输出Waiting for event to be set...,但被阻塞event.set()唤醒waiter协程,它继续执行并输出Event was set, continuing execution.- 主程序等待
waiter协程完成 - 由于事件未重置,
await event.wait()不会阻塞,立即输出后续内容
如果在waiter协程中调用了event.clear()来重置事件对象,那么第二次await event.wait()就会再次阻塞,直到事件被重新设置。这就能避免立即输出后续内容的情况。
补充说明:
event.set():将事件设置为 "set" 状态,所有等待 event.wait() 的协程都会继续执行。
event.clear():将事件重置为 "unset" 状态,之后再次调用 event.wait() 的协程会继续等待,直到事件再次被设置为 "set" 状态。
event.wait():一个协程,阻塞当前协程,直到事件变为 "set" 状态。
12、对于如下代码,有以下几点我还不太理解。 【example11_11.py】
1、对于_accpet_upload()方法, self._writer.close() await self._writer.wait_closed() 为什么数据data读取完以后,不是关闭self._reader而是关闭self._writer呢?这是什么逻辑?
2、为什么有了self._writer.close()还不够,还要await self._writer.wait_closed(),这两行到底有何区别呢?
import asyncio from asyncio import StreamReader, StreamWriter class FileUpload: def __init__(self, reader: StreamReader, writer: StreamWriter): self._reader = reader self._writer = writer self._finished_event = asyncio.Event() self._buffer = b'' self._upload_task = None def listen_for_uploads(self): self._upload_task = asyncio.create_task(self._accept_upload()) # A async def _accept_upload(self): while data := await self._reader.read(1024): self._buffer = self._buffer + data self._finished_event.set() self._writer.close() await self._writer.wait_closed() async def get_contents(self): # B await self._finished_event.wait() return self._buffer
1. 关于 self._writer.close() 和 self._reader
在网络编程中,通常有输入流(reader)和输出流(writer)两个部分。输入流用于读取客户端发送的数据,输出流用于向客户端发送数据。
- self._reader:是用于读取客户端发送的数据的流。【记住,都是在站在服务器的角度去看,就好理解】
- self._writer:是用于向客户端发送数据的流。 【记住,都是在站在服务器的角度去看,就好理解】
在_accept_upload方法中,服务器的任务是接收客户端上传的文件。当文件数据被完全读取后,服务器可能需要通知客户端上传已成功接收,或者发送一些响应数据。
在这种情况下,关闭self._writer是为了告诉客户端,服务器端已经完成了数据的接收,并且不会发送任何更多的数据了。
关闭self._writer会触发底层TCP连接的半关闭,这意味着服务器已经完成了发送数据的任务。
关闭self._reader在这种情况下可能不是必要的,因为服务器已经完成了读取数据的任务,
而且通常在TCP连接中,接收方不需要关闭读操作来通知发送方数据已经接收完毕。发送方会在数据发送完毕后,自然地关闭其写操作。
2. 关于 self._writer.close() 和 await self._writer.wait_closed()
-
self._writer.close(): 这个方法调用会关闭与
StreamWriter关联的底层传输(例如,TCP连接)。它是一个异步操作,调用这个方法本身并不会立即关闭连接,而是启动关闭过程。 -
await self._writer.wait_closed(): 这个方法调用确保当前协程等待直到底层传输真正关闭。这是必要的,因为即使调用了
close(),底层的TCP连接可能还需要一些时间来完成关闭过程,
比如等待所有缓冲区的数据被发送和确认。使用wait_closed()可以确保协程在连接完全关闭后再继续执行,这样可以避免在连接尚未完全关闭时继续执行代码,可能导致一些未定义的行为。
简而言之,self._writer.close()启动关闭过程,而await self._writer.wait_closed()确保协程等待直到关闭过程完全完成。这两个步骤通常一起使用,以确保资源被正确且完全地释放。
追问:为什么要确保协程等待直到关闭过程完全完成?
确保协程等待直到关闭过程完全完成有几个重要原因:
-
资源清理:关闭操作通常涉及到清理和释放资源,例如关闭文件描述符、网络连接等。等待关闭过程完成确保所有必要的清理工作都已完成,防止资源泄露。
-
数据完整性:在网络通信中,关闭连接之前,需要确保所有待发送的数据都已经成功发送并被对方确认接收。通过等待关闭完成,可以确保数据传输的完整性。
-
错误处理:关闭过程中可能会发生错误(例如,如果底层传输在尝试关闭时遇到问题)。等待关闭完成允许开发者捕获这些错误,并根据需要进行适当的错误处理。
-
状态同步:在某些情况下,关闭操作可能需要与其它系统组件或服务进行状态同步。等待关闭完成可以确保所有相关系统的状态是一致的。
-
避免竞态条件:如果协程在关闭操作完成之前继续执行,可能会产生竞态条件,即多个操作同时访问共享资源,导致不可预测的结果。等待关闭完成可以避免这种情况。
-
协议遵循:遵循网络协议规范,如TCP协议,需要在关闭连接时遵循特定的步骤,包括等待确认和适当的超时处理。等待关闭完成确保这些步骤得到正确执行。
-
调试和日志记录:等待关闭完成还可以帮助开发者在调试时更容易地跟踪和记录连接的生命周期,因为协程会在关闭操作完成后才继续执行。
总之,等待关闭过程完全完成是一种良好的编程实践,它有助于确保程序的健壮性、稳定性和可预测性。

浙公网安备 33010602011771号