当前位置: 首页 > 科技观察

用Python和Asyncio编写在线多人游戏(二)

时间:2023-03-21 15:36:35 科技观察

用过Python异步编程吗?在本文中,我将向您展示如何操作,并通过一个工作示例展示它:吃蛇游戏,它专为多人游戏而设计。有关介绍和理论部分,请参阅“第1部分异步化”。游戏入口在此,点此体验。3.编写主游戏循环游戏循环是每一个游戏的核心。它持续运行以读取玩家输入、更新游戏状态并在屏幕上呈现游戏结果。在网络游戏中,游戏循环分为客户端和服务器部分,因此一般有两个循环通过网络进行通信。通常客户端的作用是获取玩家输入,例如击键或鼠标移动,将数据传输到服务器,然后接收数据进行渲染。服务器处理来自玩家的所有数据,更新游戏状态,执行渲染下一帧所需的计算,然后将结果传回客户端,例如游戏中对象的新位置。如果没有充分的理由,不要混淆客户端和服务器的角色,这一点很重要。如果在客户端进行游戏逻辑的计算,很容易与其他客户端不同步。事实上,您的游戏也可以通过简单地从客户端传递数据来创建。游戏循环的迭代称为滴答。tick是一个事件,表示当前游戏循环的迭代已经结束,下一帧(或多帧)的数据准备就绪。在以下示例中,我们使用相同的客户端,它使用WebSocket从网页连接到服务器。它执行一个简单的循环,将键码发送到服务器,并显示来自服务器的所有消息。客户端代码在这里。示例3.1:基本游戏循环示例3.1源代码。我们使用aiohttp库来创建游戏服务器。它可以通过asyncio创建Web服务器和客户端。这个库的一个优点是它支持普通的http请求和websockets。所以我们不使用另一个网络服务器来呈现游戏的html页面。以下是如何启动服务器:app=web.Application()app["sockets"]=[]asyncio.ensure_future(game_loop(app))app.router.add_route('GET','/connect',wshandler)app.router.add_route('GET','/',handle)web.run_app(app)web.run_app是创建服务主任务的快捷方法,通过其run_forever()方法执行asyncio事件循环.建议您查看此方法的源代码以弄清楚服务器是如何创建和终止的。app变量是一个类似字典的对象,用于在连接的客户端之间共享数据。我们用它来存储连接的套接字列表。然后使用此列表向所有连接的客户端发送消息。asyncio.ensure_future()调用启动主游戏循环的任务,每2秒向客户端发送一次滴答消息。该任务将在同一个异步事件循环中与Web服务器并行执行。有两个网页请求处理程序:handle是提供html页面的;wshandler是处理与客户端交互的主要websocket服务器任务。在事件循环中,每个连接的客户端都会创建一个新的wshandler任务。此任务会将客户端的套接字添加到列表中,以便game_loop任务可以向所有客户端发送消息。然后它会回显客户端所做的每个击键以及消息。在started任务中,我们在asyncio的主事件循环中启动worker循环。当任务中的任何一个使用await语句等待协程结束时,任务之间就会发生切换。例如,asyncio.sleep只是将程序的执行权交给了调度器一段指定的时间;ws.receive等待websocket消息,此时调度器可能会切换到其他任务。在浏览器中打开主页,连接到服务器,然后按任意键尝试。它们的键值从服务器返回,每2秒这个数字被发送给游戏循环中所有客户端的滴答消息覆盖。我们刚刚创建了一个处理客户端击键的服务器,主游戏循环在后台进行一些处理,定期同时更新所有客户端。示例3.2:根据请求开始游戏示例3.2的源代码在前面的示例中,游戏循环在服务器的生命周期内运行。但实际上,如果没有人连接到服务器,模拟游戏循环通常是不合理的。此外,同一服务器上可能存在不同的“游戏室”。在这种假设下,每个玩家“创建”一个其他用户可以加入的游戏会话(例如,多人游戏中的一场比赛或大型多人游戏中的一个实例)。游戏循环在游戏会话开始时执行。在此示例中,我们使用全局标志来检测游戏循环是否正在执行。当第一个用户启动连接时启动它。最初,游戏循环不执行,标志设置为False。游戏循环是通过客户端的handle方法启动的。ifapp["game_is_running"]==False:asyncio.ensure_future(game_loop(app))当game_loop()运行时,该标志设置为True;当所有客户端断开连接时,它再次设置为False。示例3.3:管理任务示例3.3源代码此示例用于解释如何使用任务对象。我们不使用标签,而是将游戏循环的任务直接存储在游戏循环的全局字典中。在像这样的简单示例中不一定是最佳的,但有时您可能需要控制所有已启动的任务。ifapp["game_loop"]isNoneor\app["game_loop"].cancelled():app["game_loop"]=asyncio.ensure_future(game_loop(app))其中ensure_future()返回我们存储在全局字典中的任务对象,当所有用户都断开连接时,我们使用以下方法取消任务:app["game_loop"].cancel()这个cancel()调用会告诉调度器不要将执行权传递给这个协程,并将它的状态设置为canceled:取消,然后可以通过cancel()方法查看是否已经取消。这里有一个值得一提的小提示:当你持有一个任务对象的外部引用时,在任务执行过程中发生异常时,不会抛出该异常。相反,为此任务设置一个异常状态,并通过exception()方法检查是否发生了异常。这种静默故障在调试时不是很有用。因此,您可能希望将其替换为抛出所有异常。您可以通过在所有未完成的任务上显式调用result()来完成此操作。这可以通过以下回调来实现:app["game_loop"].add_done_callback(lambdat:t.result())如果我们打算在代码中取消任务,但不想生成CancelError异常,则有检查取消状态的要点:app["game_loop"].add_done_callback(lambdat:t.result()ifnott.cancelled()elseNone)请注意,仅当您持有对任务对象的引用时才需要这样做。在前面的例子中,所有的异常都是在没有额外回调的情况下抛出的。例3.4:等待多个事件例3.4源码在很多场景下,客户端的处理方法中需要等待多个事件的发生。除了来自客户端的消息,您可能还需要等待不同类型的事件发生。例如,如果您的游戏有时间限制,那么您可能需要等待来自计时器的信号。或者您需要使用管道来等待来自其他进程的消息。或者使用分布式消息系统从网络中的其他服务器获取信息。为简单起见,此示例基于示例3.1。但在这个例子中,我们使用Condition对象来保持游戏循环与连接的客户端同步。我们不保留套接字的全局列表,因为套接字仅在该处理程序方法中使用。当游戏循环停止迭代时,我们使用Condition.notify_all()方法通知所有客户端。此方法允许在asyncio的事件循环中使用发布/订阅模式。为了等待这两个事件,首先我们使用ensure_future()将可等待对象封装在任务中。ifnotrecv_task:recv_task=asyncio.ensure_future(ws.receive())ifnottick_task:awaittick.acquire()tick_task=asyncio.ensure_future(tick.wait())在我们调用Condition.wait()之前,我们需要在它之后获取一个未来把锁。这就是我们首先调用tick.acquire()的原因。调用tick.wait()后,锁被释放,以便其他协程可以使用它。但是当我们收到通知后,会重新获取锁,所以需要在收到通知后调用tick.release()释放。我们使用asyncio.wait()协程来等待两个任务。done,pending=awaitasyncio.wait([recv_task,tick_task],return_when=asyncio.FIRST_COMPLETED)该程序将阻塞,直到列表中的任何任务完成。然后它返回两个列表:已完成执行的任务列表和仍在执行的任务列表。如果任务执行完成,则其对应的变量被赋值为None,因此可能会在下一次迭代时再次创建。示例3.5:组合多个线程示例3.5源代码在这个示例中,我们组合了asyncio循环和线程,在单独的线程中执行主游戏循环。我之前提到过,由于GIL,Python代码的真正并行执行是不可能的。所以使用其他线程来执行复杂的计算并不是一个好主意。但是,在使用asyncio时合并线程是有原因的:当我们使用的其他库不支持asyncio时需要它。从主线程调用这些库会阻塞循环的执行,因此异步使用它们的唯一方法是从不同的线程使用它们。我们使用异步循环的run_in_executor()方法和ThreadPoolExecutor来执行游戏循环。请注意,game_loop()不再是协程。它是由另一个线程执行的函数。然而,我们需要与主线程交互以在游戏事件到达时通知客户端。asyncio本身不是线程安全的,它提供了可以在其他线程中执行你的代码的方法。普通函数有call_soon_threadsafe(),协程有run_coroutine_threadsafe()。我们在notify()协程中添加了通知客户端游戏tick的代码,然后通过另一个线程执行主事件循环。defgame_loop(asyncio_loop):print("Gameloopthreadid{}".format(threading.get_ident()))asyncdefnotify():print("Notifythreadid{}".format(threading.get_ident()))awaittick.acquire()勾选。notify_all()tick.release()while1:task=asyncio.run_coroutine_threadsafe(notify(),asyncio_loop)#blockingthethreadsleep(1)#makesurethetaskhasfinishedtask.result()当你执行这个例子的时候,你会看到“Notifythreadid”和“Mainthreadid”是相等的,因为notify()协程是在主线程中执行的。同时sleep(1)在另一个线程中执行,因此它不会阻塞主事件循环。Example3.6:Multi-ProcessingandScalingExample3.6源代码单线程服务器可能运行良好,但它只能使用一个CPU核心。为了将服务扩展到多个核心,我们需要执行多个进程,每个进程执行自己的事件循环。这样我们就需要在进程之间交换信息或者共享游戏数据。而且,游戏中往往需要进行复杂的计算,例如寻路等。这些任务有时无法在一个游戏刻内快速完成。不建议在协程中进行耗时计算,因为会阻塞事件的处理。在这种情况下,将此复杂任务卸载到其他并行执行的进程可能更有意义。使用多核最简单的方法是使用单核启动多台服务器,就像前面的例子一样,每台服务器占用不同的端口。您可以使用supervisord或其他过程控制系统。这时候就需要HAProxy这样的负载均衡器,让连接的客户端分布在多个进程中。已经有一些改编将asyncio与一些流行的消息传递和存储系统连接起来。例如:aiomcacheformemcachedclientaiozmqforzeroMQaioredisforRedisstorage,supportpublish/subscribe你可以在github或者pypi上找到其他包,大部分都是以aio开头的。使用网络服务可能更有效地存储持久状态和交换一些信息。但是如果你需要做进程间通信的实时处理,它的性能可能就不够用了。在这一点上,使用标准的unix管道可能更合适。asyncio支持管道,并且在aiohttp存储库中有一个使用管道的服务器的非常低级的示例。在当前的例子中,我们使用Python的高级类库multiprocessing在不同的核上启动复杂的计算,并使用multiprocessing.Queue在进程间交换消息。不幸的是,当前的多处理实现与asyncio不兼容。所以每次调用阻塞方法都会阻塞事件循环。但是此时线程可以提供帮助,因为如果多处理代码在不同的线程中执行,它不会阻塞主线程。我们需要做的就是将所有进程间通信放在另一个线程中。这个例子将解释如何使用这个方法。与上面的多线程示例非常相似,但是我们从线程创建了一个新进程。defgame_loop(asyncio_loop):#coroutinetoruninmainthreadasyncdefnotify():awaittick.acquire()tick.notify_all()tick.release()queue=Queue()#functiontoruninadifferentprocessdefworker():while1:print("doingheavycalculationinprocess{}".format(os.getp()))sleep(1)queue.put("calculationresult")Process(target=worker).start()while1:#blocksthisthreadbutnotmainthreadwitheventloopresult=queue.get()print("getting{}inprocess{}".format(结果,os.getpid()))task=asyncio.run_coroutine_threadsafe(notify(),asyncio_loop)task.result()这里我们在另一个进程中运行worker()函数。它包含一个执行复杂计算并将结果放在队列中的循环,队列是multiprocessing.Queue的一个实例。然后我们可以在另一个线程的主事件循环中获取结果并通知客户端,就像示例3.5一样。这个例子非常简单,它没有正确结束进程。在真实游戏中,我们可能需要另一个队列来将数据传递给工作人员。有一个项目叫做aioprocessing,它封装了multiprocessing,使其兼容asyncio。但实际上它只是使用了与上面示例完全相同的方法:从线程创建进程。它不会给你带来任何方便,只是它使用一个简单的界面来隐藏这些背后的技巧。希望在下一个版本的Python中,我们将有一个基于协程的多处理库,支持异步。笔记!如果你创建一个与主线程或主进程不同的线程或子进程来运行另一个asyncio事件循环,你需要显式地使用asyncio.new_event_loop()来创建循环,否则程序可能无法正常工作。使用Python和Asyncio编写在线多人游戏(一)使用Python和Asyncio编写在线多人游戏(三)