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





5.00/5 (2投票s)
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. 解决方案
在这个领域有两个要点:
- I/O 通过文件描述符进行,即套接字。
- 除了所有其他事件类型之外,还有一个定时器事件,它会在超时到期时触发。
提出的解决方案是在‘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 上找到。
GUI 编程的事件机制可以在 Xlib 编程手册的“事件”一章中找到。
XLIB Programming Manual, Rel. 5, Third Edition
在 Python 中,“事件循环”是“异步 I/O”主题的一个子部分。
6. 设计
设计思路是在事件循环管理器中注册一个定时器事件。定时器事件回调函数将在非阻塞超时模式下对 I/O 文件描述符执行‘select
’。如果描述符已准备好进行 I/O,I/O 就会发生。定时器回调函数将返回到事件循环处理,或者设置零超时。
7. 实验输出
我们有一个场景,其中 Blender(一个 3D GUI 建模工具)需要运行一个外部于 GUI 工具且作为独立进程执行的 Python 程序。默认情况下,Blender 支持“应用程序内”的 Python 解释器。请参见下图:
我们需要 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)
脚本可以在两个终端中执行,如下所示:
- 第一个终端
blender — python blender_server.py
- 第二个终端
python3 blender_client.py <pythonprogram1> <pythonprogram2> <pythonprogram3>
下图展示了打开两个终端:
7.1 程序说明
客户端程序接受多个 Python 程序作为命令行参数。程序名称连接成一个string
并发送给服务器。服务器解析请求并逐个处理每个 Python 文件。每次处理完一个文件后,它都会返回到 Timer Event Loop,以便事件循环管理器有机会处理其他事件。
发送给 Python 服务器的 Python 程序被连接成一个string
。服务器逐个处理。每次处理完一个文件后都会返回到事件循环。
7.2 视频
关于为客户端和服务器启动独立终端的视频。
7.3 立方体添加和删除的 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 按钮选项和上下文菜单选项来执行脚本。
与服务器的数据/字符串通信将在“docket”窗口中显示。
10. 进一步研究
- Advanced Programming in the UNIX Environment, W. Richard Stevens. Addison-Wesley Professional Computing Series
- 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