65.9K
CodeProject 正在变化。 阅读更多。
Home

在为阻塞的外部 I/O 服务时实现非阻塞 GUI – Blender Python 支持

starIconstarIconstarIconstarIconstarIcon

5.00/5 (2投票s)

2024年1月13日

CPOL

5分钟阅读

viewsIcon

4196

Python GUI 非阻塞,使用定时器事件、'select' API、Blender 示例

1. 引言

GUI 应用程序在回调或异步调用流程中工作,其架构是事件驱动的。维护一个事件循环,所有已注册/调度的回调函数会逐个执行。一个事件,在需要处理时,必须向事件循环管理器注册。一个回调函数与一个事件相关联。事件循环中的事件通过执行关联的回调函数来触发。

GUI 回调是针对 GUI 事件调用的。在事件回调中执行阻塞 I/O 会延迟 GUI 事件的处理,导致 GUI 冻结。本文讨论如何在 GUI 框架中服务阻塞的外部 I/O,同时保持 GUI 不冻结。

2. 问题

如何在 GUI 相关应用程序中执行阻塞的外部 I/O?

3. 场景

GUI 应用程序以事件回调程序流而不是顺序程序流(例如,'C' 编程)工作。一个事件及其关联的回调函数需要向事件循环管理器注册。一旦事件触发,事件循环调度器/管理器就会调用关联的回调函数。从任何事件回调函数中进行阻塞 I/O 调用都会导致 GUI 冻结,因为程序执行不会返回到事件循环。这会阻止事件循环管理器从事件循环中调度任何 GUI 事件。

4. 解决方案

在这个领域有两个要点:

  1. I/O 通过文件描述符进行,即套接字。
  2. 除了所有其他事件类型之外,还有一个定时器事件,它会在超时到期时触发。

提出的解决方案是在‘Timer’事件回调中执行 I/O 操作,其中使用‘select’(I/O 描述符多路复用器)API 来以非阻塞方式检查一组文件描述符上的读写活动。当‘select’API 在没有超时的情况下返回时,I/O 就会发生。超时设置为零,使定时器事件回调完全非阻塞。

5. 外部参考

select’’API 在未提供超时参数时是阻塞的,否则在提供超时时是非阻塞的。‘select’API 的设备驱动程序实现可以在 O'Reilly 出版的《Linux Device Drivers》第三版一书中找到。

打开书籍 - Linux Device Drivers, 3rd Edition

在“高级字符驱动程序操作”一章的“轮询和选择”子节中。而 Python 的实现文档可以在 docs.python.org 上找到。

select - 等待 I/O 完成

GUI 编程的事件机制可以在 Xlib 编程手册的“事件”一章中找到。

XLIB Programming Manual, Rel. 5, Third Edition

在 Python 中,“事件循环”是“异步 I/O”主题的一个子部分。

asyncio - 异步 I/O

6. 设计

设计思路是在事件循环管理器中注册一个定时器事件。定时器事件回调函数将在非阻塞超时模式下对 I/O 文件描述符执行‘select’。如果描述符已准备好进行 I/O,I/O 就会发生。定时器回调函数将返回到事件循环处理,或者设置零超时。

7. 实验输出

我们有一个场景,其中 Blender(一个 3D GUI 建模工具)需要运行一个外部于 GUI 工具且作为独立进程执行的 Python 程序。默认情况下,Blender 支持“应用程序内”的 Python 解释器。请参见下图:

Blender 应用程序内 Python IDE

我们需要 Blender Python 支持作为应用程序外的 Python 应用程序,类似这样:

Python 客户端通过套接字连接到运行 Python 服务器代码的 Blender。

Python 服务器代码向事件循环管理器注册一个Timer事件。在定时器事件回调中,Python 服务器通过‘select’API 调用来检查 I/O。如果超时发生或描述符已准备好(数据到达),定时器事件回调将在处理请求后返回。客户端和服务器 Python 代码。

$cat blender_client2.py 
#!/usr/bin/env python 
#blender_client.py script1.py script2.py 
#developed at Minh, Inc. https://youtube.com/@minhinc 
import sys,time,select 
PORT = 8081 
HOST = "localhost" 
def main(): 
 import sys 
 import socket 
 if len(sys.argv)<2: 
 print(f'---usage---\npython3 blender_client.py blenderpythonscript.py blenderpythonscript2.py ..') 
 sys.exit(-1)
 clientsocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 
 try: 
 clientsocket.connect((HOST, PORT)) 
 except Exception as exc: 
 print(f'E Error in connection with server, probably try after sometime/minutes... Exception type -
> {type(exc)=}') 
 sys.exit(-1)
 else: 
 print(f'I blender_client connection to server successful') 
 filestosend=' '.join(sys.argv[1:]) 
 print(f'sending file(s) -> {filestosend} to the server') 
 clientsocket.sendall(filestosend.encode("utf-8") + b'\x00') 
 print(f'I blender_client message sent successfully, waiting for response..') 
 while True: 
 messagerecved=clientsocket.recv(4096) 
 if not messagerecved: 
 print(f'Empty message received, sleeping for 10 secs...') 
 time.sleep(10) 
 else: 
 print(f'Message received {messagerecved=}, exiting...')
 clientsocket.close() 
 break 
if __name__ == "__main__": 
 main() 
$cat blender_server2.py 
#blender --python blender_server.py 
#developed at Minh, Inc. https://youtube.com/@minhinc 
import socket,time,select,re,datetime 
import bpy 
PORT = 8081 
HOST = "localhost" 
PATH_MAX = 4096 
def execfile(filepath): 
 import os 
 global_namespace = { 
 "__file__": filepath, 
 "__name__": "__main__", 
 } 
 with open(filepath, 'rb') as file: 
 exec(compile(file.read(), filepath, 'exec'), global_namespace) 
def main(): 
 global serversocket,read_list,file_list,connection,result_list 
 file_list=[] 
 result_list=[] 
 connection=None 
 serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 
 serversocket.bind((HOST, PORT)) 
 serversocket.listen(5) #accept upto 6 connect and messages 
 print("Listening on %s:%s" % (HOST, PORT)) 
 read_list=[serversocket] 
def handle_data(): 
 global file_list,connection,result_list 
 timeout=20 
 def send_data(): 
 nonlocal timeout 
 print(f'I blender_server executing file {file_list[0]} full {file_list=} ') 
 try: 
 execfile(file_list[0]) 
 print(f'executed successfully {file_list[0]=}') 
 result_list.append(f'{file_list[0]} - success') 
 except Exception as exc: 
 print(f'Error while executing {file_list[0]=} {exc=}') 
 result_list.append(f'{file_list[0]} - failed exception {exc}') 
 file_list[0:1]=[] 
 timeout=2 
 if file_list: 
 send_data() 
 else: 
 if connection: 
 connection.sendall('\n'.join(result_list).encode('utf-8') + b'\x00') 
 print("response ",'\n'.join(result_list)," sent to client") 
 connection.close() 
 connection=None 
 result_list=[] 
 readable,writable,errored=select.select(read_list,[],[],0.0) 
 print(f'E handle_data() {(readable,writable,errored)=} {read_list=} at time -> 
{datetime.datetime.now():%H:%M:%S}') 
 for s in readable: 
 if s in read_list: 
 connection, address = serversocket.accept() 
 print(f'I blender_server connection accepted {connection=} {address=}') 
 file_list = re.split(r'\s+',re.split(b'\x00',connection.recv(PATH_MAX))[0].decode()) 
 print(f'I blender_server data received {file_list=}')
 send_data() 
 print(f'handle_data, returning {timeout} second timeout..') 
 return timeout 
if __name__ == "__main__": 
 main() 
 bpy.app.timers.register(handle_data) 

脚本可以在两个终端中执行,如下所示:

  1. 第一个终端

    blender — python blender_server.py

  2. 第二个终端

    python3 blender_client.py <pythonprogram1> <pythonprogram2> <pythonprogram3>

下图展示了打开两个终端:

客户端和服务器的两个终端

7.1 程序说明

客户端程序接受多个 Python 程序作为命令行参数。程序名称连接成一个string并发送给服务器。服务器解析请求并逐个处理每个 Python 文件。每次处理完一个文件后,它都会返回到 Timer Event Loop,以便事件循环管理器有机会处理其他事件。

发送给 Python 服务器的 Python 程序被连接成一个string。服务器逐个处理。每次处理完一个文件后都会返回到事件循环。

7.2 视频

关于为客户端和服务器启动独立终端的视频。

视频 - 从客户端向服务器触发 Python 脚本。

7.3 立方体添加和删除的 GIF 动画

通过客户端应用程序触发的脚本添加和删除立方的 GIF 动画

7.4 立方体添加和删除代码

$ cat cubeadd_y.py 
import bpy 
import bmesh 
import mathutils 
bm = bmesh.new() 
bmesh.ops.create_cube(bm, size=4) 
mesh = bpy.data.meshes.new('Basic_Cube') 
bm.to_mesh(mesh) 
mesh.update() 
bm.free() 
basic_cube = bpy.data.objects.new("Basic_Cube", mesh) 
basic_cube.matrix_world.translation += basic_cube.matrix_world.to_3x3() @ 
mathutils.Vector((0.0,6.0,0.0)) 
bpy.context.collection.objects.link(basic_cube) 
$ cat cubeadd_x.py 
import bpy 
import bmesh 
import mathutils 
bm = bmesh.new() 
bmesh.ops.create_cube(bm, size=4) 
mesh = bpy.data.meshes.new('Basic_Cube') 
bm.to_mesh(mesh) 
mesh.update() 
bm.free() 
basic_cube = bpy.data.objects.new("Basic_Cube", mesh)
basic_cube.matrix_world.translation += basic_cube.matrix_world.to_3x3() @ mathutils.Vector((-
6.0,0.0,0.9)) 
bpy.context.collection.objects.link(basic_cube) 
$ cat cubedelete.py 
import bpy 
import mathutils 
try:
 cube = bpy.data.objects['Cube'] 
 bpy.data.objects.remove(cube, do_unlink=True) 
except: 
 print("Object bpy.data.objects['Cube'] not found") 
 
bpy.ops.outliner.orphans_purge() 

8. 它是如何工作的?

两个 Python 程序(客户端和服务器)通过“套接字”进程间通信进行交互。套接字也可以用于计算机之间的通信。在这种情况下,IP 地址需要是服务器的实际 IP 地址。Blender 以“ --python”命令行参数以脚本模式启动。Blender 在主线程中启动 Python 程序。Python 程序不是立即执行任务,而是通过代码向事件循环注册一个‘Timer Event’。

交互流程活动图

bpy.app.timers.register(handle_data)’ 传递‘handle_event’作为回调函数。事件回调函数‘handle_data’在主循环中调用,它使用‘select’作为 I/O 多路复用器以非阻塞模式处理 I/O。一旦连接到达,‘read descriptor is set’,连接请求就会被读取和处理。对于多个文件,Timer 回调(返回到事件主循环)的超时时间为 0 秒。此处使用 2 秒是为了使解释更直观。在处理每个 Python 脚本文件之间返回到事件循环,使事件循环管理器有机会执行其他 GUI 事件,从而使 GUI 看起来具有交互性。

9. 进一步增强

客户端 Python 脚本可以通过 IDE 编辑器进行编辑。编辑器将提供 GUI 按钮选项和上下文菜单选项来执行脚本。

建议的 IDE 编辑器带有独立的工具栏按钮和上下文菜单来执行脚本

与服务器的数据/字符串通信将在“docket”窗口中显示。

10. 进一步研究

  1. Advanced Programming in the UNIX Environment, W. Richard Stevens. Addison-Wesley Professional Computing Series
  2. TCP/IP Illustrated, W. Richard Stevens : The Protocols Volume 1, The implementation Volume 2, TCP for Transactions, HTTP, NNTP and the UNIX Domain Protocols Volume 3
© . All rights reserved.