GUI主线程中非阻塞嵌套函数调用的设计模式?

发布于 2025-01-10 02:25:48 字数 10032 浏览 3 评论 0 原文

当 PyQt GUI 应用程序具有多个调用一个又一个外部子进程的嵌套调用时,我需要一些帮助来识别设计模式或使 PyQt GUI 应用程序非阻塞的技术。伪代码是:

class MainWindow:
    def executeInSubprocess(self, theCommand):
        subprocess.run(theCommand, check = True)
        #program control reaches here only after the command executed by subprocess.run completes execution
  
    def function1(self):
        commandToExecute = ['python', '-m', 'someProgram.py', '--CLA_option']
        self.executeInSubprocess(commandToExecute)
        self.function2()
        #some commands to update the GUI widgets
    
    def function2(self):
        #Some lines here gather data generated by running function1
        self.function3(data)
  
    def function3(self, data): 
        commandToExecute = ['python', '-m', 'anotherProgram.py', '--CLA_option']
        for oneData in data:
            newCommand = commandToExecute[:] #copy contents of list
            newCommand.append('--someOption')
            newCommand.append(oneData)
            self.executeInSubprocess(newCommand)

当用户单击 GUI 上的按钮时,将调用 function1。该按钮保持禁用状态,直到 #some 命令更新 GUI 小部件 行,该按钮重新启用。

问题:

  1. 如何设计使得executeInSubprocess是非阻塞的?我想尝试将 subprocess.run 替换为 QProcess,它不会等到外部命令完成,从而不会冻结 GUI。
  2. executeInSubprocessfunction3for 循环内多次调用,并且依赖于 function1 生成的数据。

这些函数并不是太大,因此重构它应该不成问题。我需要的是关于设计这种场景的好方法的知识。我考虑过将对 function1function2function3 的引用放入列表中,并让类中的函数一一调用它们,但是那么 function3 中的 executeInSubprocess 会使事情变得复杂。

更新:我提供了一个示例,说明代码可以变得多么复杂和交织。

#!/usr/bin/python
# -*- coding: ascii -*-
from PyQt5.QtWidgets import (QApplication, QMainWindow, QPushButton, QPlainTextEdit, QVBoxLayout, QWidget)
from PyQt5.QtCore import QProcess
from collections import deque
import sys

# Note: The objective was to design a way to execute a function and return control back to the main thread so that the GUI does not freeze.
# But it had to be done in a way that a function could invoke another function, which could invoke another function. Each of these functions
# can have one or more external commands that they need to run. So after invoking each external process, control has to return to the main thread
# and then after running the external process completes, the next command or function needs to run and return control to the main thread and 
# so on until all functions and external commands are exhausted. What's given below is a very crude, tangled design that needs to be improved.

class ExternalProcessInvoker:
    def __init__(self):
        self.externalProcess = None
        self.currentFunction = None
        self.functionQueue = deque() #a sequence of functions to be executed
        self.commandQueue = dict() #{the key is a function reference: the value is a list of commands [['python', 'prog1.py'], ['python', 'prog2.py'], ...]}
        self.FIRST_ELEMENT_OF_LIST = 0
        self.UTF8 = 'utf8'        
        
    def registerTheMessageFunction(self, functionReference):
        self.message = functionReference
    
    def startTheProcess(self):
        while self.functionQueue:
            #print('Elements in queue are: ', self.functionQueue)
            theFunctionToExecute = self.functionQueue.popleft()
            #---run the function
            theFunctionToExecute(theFunctionToExecute) #The function may populate commandQueue when run
            self.currentFunction = theFunctionToExecute
            self.checkForMoreFunctionPopulatedCommandsToExecute()

    def checkForMoreFunctionPopulatedCommandsToExecute(self):
        #---check if commandQueue is populated with any external process that needs to be run    
        if self.currentFunction in self.commandQueue:
            if self.commandQueue[self.currentFunction]:#contains some command
                command = self.commandQueue[self.currentFunction].popleft()
                #command = self.convertToUTF8(command)
                if self.externalProcess is None:  # No process running.
                    #---run the command
                    self.message('Executing ' + str(command)); print('Executing ', command)
                    self.externalProcess = QProcess() 
                    self.externalProcess.readyReadStandardOutput.connect(self.functionForStdout)
                    self.externalProcess.readyReadStandardError.connect(self.functionForStderr)
                    self.externalProcess.stateChanged.connect(self.functionForStateChange)
                    self.externalProcess.finished.connect(self.processCompleted)  # Clean up once complete.
                    self.externalProcess.start(command[self.FIRST_ELEMENT_OF_LIST], command[self.FIRST_ELEMENT_OF_LIST:]) #the first argument is the first command element (usually 'python'). The second argument is a list of all the remaining elements of the command
                    print('Process ID: ', self.externalProcess.processId())    
        else:
            self.currentFunction = None              
    
    def convertToUTF8(self, command):
        return [oneString.encode('utf-8') for oneString in command]

    def stopTheProcess(self):
        self.message('Attempting to stop the processes')
        if self.externalProcess:
            self.externalProcess.kill()
        self.message('Also cleared ' + len(self.functionQueue) + ' items from execution queue')
        self.functionQueue.clear()

    def functionForStderr(self):
        data = self.externalProcess.readAllStandardError()
        stderr = bytes(data).decode(self.UTF8)
        self.message(stderr)

    def functionForStdout(self):
        data = self.externalProcess.readAllStandardOutput()
        stdout = bytes(data).decode(self.UTF8)
        self.message(stdout)

    def functionForStateChange(self, state):
        states = {QProcess.NotRunning: 'Not running', QProcess.Starting: 'Starting', QProcess.Running: 'Running'}
        state_name = states[state]
        self.message(f'State changed: {state_name}')

    def processCompleted(self):
        self.message('Process finished.')
        self.externalProcess = None
        self.checkForMoreFunctionPopulatedCommandsToExecute()

    def addThisFunctionToProcessingQueue(self, functionReference):
        #print('Received request to add this function to processing queue: ', functionReference)
        #print('What's already in processing queue: ', self.functionQueue)
        self.functionQueue.append(functionReference)

    def addThisCommandToProcessingList(self, command, functionReference):
        if functionReference not in self.commandQueue:
            self.commandQueue[functionReference] = deque()
        self.commandQueue[functionReference].append(command)
        #print('\nfunc ref: ', functionReference, ' command: ', command)
        self.message('Added ' + str(command) + ' for processing')
        #print(self.commandQueue)


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.proc = ExternalProcessInvoker()
        self.proc.registerTheMessageFunction(self.message)

        self.executeButton = QPushButton('Execute') #TODO: Disable button until processing completes or processes are killed
        self.executeButton.pressed.connect(self.initiateProcessing)
        self.killButton = QPushButton('Kill process') #TODO: Disable button until processing is started
        self.killButton.pressed.connect(self.killProcessing)        
        self.textDisplay = QPlainTextEdit()
        self.textDisplay.setReadOnly(True)

        boxLayout = QVBoxLayout()
        boxLayout.addWidget(self.executeButton)
        boxLayout.addWidget(self.killButton)
        boxLayout.addWidget(self.textDisplay)

        theWidget = QWidget()
        theWidget.setLayout(boxLayout)

        self.setCentralWidget(theWidget)
        
    def message(self, mssg):
        self.textDisplay.appendPlainText(mssg)
        
    def initiateProcessing(self):
        print('Processing initiated')
        #---first populate the functions and commands to execute
        self.proc.addThisFunctionToProcessingQueue(self.function1)
        self.proc.addThisFunctionToProcessingQueue(self.function2)
        self.proc.addThisFunctionToProcessingQueue(self.function3)
        self.proc.startTheProcess()
        
    def function1(self, functionReference):
        self.message('Executing function1')
        command = ['python', 'dummyScript1.py']
        self.proc.addThisCommandToProcessingList(command, functionReference)
        
    def function2(self, functionReference):
        self.message('Executing function2')
        
    def function3(self, functionReference):
        self.message('Executing function3')
        maxInvocations = 5
        secondsToSleep = 1
        for _ in range(maxInvocations):
            command = ['python', 'dummyScript2.py', str(secondsToSleep)]
            self.proc.addThisCommandToProcessingList(command, functionReference) 
            secondsToSleep = secondsToSleep + 1

        self.message('Processing complete')
        print('Completed')
    
    def killProcessing(self):
        self.proc.stopTheProcess()

app = QApplication(sys.argv)
w = MainWindow()
w.show()
app.exec_()

虚拟脚本 1:

#!/usr/bin/python
# -*- coding: ascii -*-
import time
import datetime

sec = 3
print(datetime.datetime.now(), " Going to sleep for ", sec, " seconds")
time.sleep(sec)
print(datetime.datetime.now(), " Done sleeping dummy script1")

虚拟脚本 2:

#!/usr/bin/python
# -*- coding: ascii -*-
import time

def gotosleep(sleepTime):
    print("Going to sleep for ", sleepTime, " seconds")
    time.sleep(sleepTime)
    print("Done sleeping for ", sleepTime, " seconds in dummy script2")

if __name__ == "__main__":
    FIRST_EXTRA_ARG = 1
    sleepTime = sys.argv[FIRST_EXTRA_ARG]
    gotosleep(sleepTime)

I need some help with identifying a design pattern or a technique to make a PyQt GUI application non-blocking, when it has multiple nested calls which invoke one external subprocess after another. The pseudocode is:

class MainWindow:
    def executeInSubprocess(self, theCommand):
        subprocess.run(theCommand, check = True)
        #program control reaches here only after the command executed by subprocess.run completes execution
  
    def function1(self):
        commandToExecute = ['python', '-m', 'someProgram.py', '--CLA_option']
        self.executeInSubprocess(commandToExecute)
        self.function2()
        #some commands to update the GUI widgets
    
    def function2(self):
        #Some lines here gather data generated by running function1
        self.function3(data)
  
    def function3(self, data): 
        commandToExecute = ['python', '-m', 'anotherProgram.py', '--CLA_option']
        for oneData in data:
            newCommand = commandToExecute[:] #copy contents of list
            newCommand.append('--someOption')
            newCommand.append(oneData)
            self.executeInSubprocess(newCommand)

When the user clicks a button on the GUI, the function1 gets invoked. The button remains disabled until the #some commands to update the GUI widgets line, where the button is re-enabled.

The problem:

  1. How to design it in such a way that executeInSubprocess is non-blocking? I'd like to try replacing subprocess.run with QProcess, which would not wait until the external command completes, thereby not freezing the GUI.
  2. executeInSubprocess is called inside a for loop multiple times in function3, and is dependent on the data generated by function1.

These functions are not too large, so it shouldn't be an issue refactoring it. What I need, is knowledge on a good way to design such a scenario. I've considered putting references to function1, function2 and function3 in a list and having a function in a class call them one by one, but then the executeInSubprocess in function3 complicates things.

Update: I've provided an example of how complex and intertwined the code can get.

#!/usr/bin/python
# -*- coding: ascii -*-
from PyQt5.QtWidgets import (QApplication, QMainWindow, QPushButton, QPlainTextEdit, QVBoxLayout, QWidget)
from PyQt5.QtCore import QProcess
from collections import deque
import sys

# Note: The objective was to design a way to execute a function and return control back to the main thread so that the GUI does not freeze.
# But it had to be done in a way that a function could invoke another function, which could invoke another function. Each of these functions
# can have one or more external commands that they need to run. So after invoking each external process, control has to return to the main thread
# and then after running the external process completes, the next command or function needs to run and return control to the main thread and 
# so on until all functions and external commands are exhausted. What's given below is a very crude, tangled design that needs to be improved.

class ExternalProcessInvoker:
    def __init__(self):
        self.externalProcess = None
        self.currentFunction = None
        self.functionQueue = deque() #a sequence of functions to be executed
        self.commandQueue = dict() #{the key is a function reference: the value is a list of commands [['python', 'prog1.py'], ['python', 'prog2.py'], ...]}
        self.FIRST_ELEMENT_OF_LIST = 0
        self.UTF8 = 'utf8'        
        
    def registerTheMessageFunction(self, functionReference):
        self.message = functionReference
    
    def startTheProcess(self):
        while self.functionQueue:
            #print('Elements in queue are: ', self.functionQueue)
            theFunctionToExecute = self.functionQueue.popleft()
            #---run the function
            theFunctionToExecute(theFunctionToExecute) #The function may populate commandQueue when run
            self.currentFunction = theFunctionToExecute
            self.checkForMoreFunctionPopulatedCommandsToExecute()

    def checkForMoreFunctionPopulatedCommandsToExecute(self):
        #---check if commandQueue is populated with any external process that needs to be run    
        if self.currentFunction in self.commandQueue:
            if self.commandQueue[self.currentFunction]:#contains some command
                command = self.commandQueue[self.currentFunction].popleft()
                #command = self.convertToUTF8(command)
                if self.externalProcess is None:  # No process running.
                    #---run the command
                    self.message('Executing ' + str(command)); print('Executing ', command)
                    self.externalProcess = QProcess() 
                    self.externalProcess.readyReadStandardOutput.connect(self.functionForStdout)
                    self.externalProcess.readyReadStandardError.connect(self.functionForStderr)
                    self.externalProcess.stateChanged.connect(self.functionForStateChange)
                    self.externalProcess.finished.connect(self.processCompleted)  # Clean up once complete.
                    self.externalProcess.start(command[self.FIRST_ELEMENT_OF_LIST], command[self.FIRST_ELEMENT_OF_LIST:]) #the first argument is the first command element (usually 'python'). The second argument is a list of all the remaining elements of the command
                    print('Process ID: ', self.externalProcess.processId())    
        else:
            self.currentFunction = None              
    
    def convertToUTF8(self, command):
        return [oneString.encode('utf-8') for oneString in command]

    def stopTheProcess(self):
        self.message('Attempting to stop the processes')
        if self.externalProcess:
            self.externalProcess.kill()
        self.message('Also cleared ' + len(self.functionQueue) + ' items from execution queue')
        self.functionQueue.clear()

    def functionForStderr(self):
        data = self.externalProcess.readAllStandardError()
        stderr = bytes(data).decode(self.UTF8)
        self.message(stderr)

    def functionForStdout(self):
        data = self.externalProcess.readAllStandardOutput()
        stdout = bytes(data).decode(self.UTF8)
        self.message(stdout)

    def functionForStateChange(self, state):
        states = {QProcess.NotRunning: 'Not running', QProcess.Starting: 'Starting', QProcess.Running: 'Running'}
        state_name = states[state]
        self.message(f'State changed: {state_name}')

    def processCompleted(self):
        self.message('Process finished.')
        self.externalProcess = None
        self.checkForMoreFunctionPopulatedCommandsToExecute()

    def addThisFunctionToProcessingQueue(self, functionReference):
        #print('Received request to add this function to processing queue: ', functionReference)
        #print('What's already in processing queue: ', self.functionQueue)
        self.functionQueue.append(functionReference)

    def addThisCommandToProcessingList(self, command, functionReference):
        if functionReference not in self.commandQueue:
            self.commandQueue[functionReference] = deque()
        self.commandQueue[functionReference].append(command)
        #print('\nfunc ref: ', functionReference, ' command: ', command)
        self.message('Added ' + str(command) + ' for processing')
        #print(self.commandQueue)


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.proc = ExternalProcessInvoker()
        self.proc.registerTheMessageFunction(self.message)

        self.executeButton = QPushButton('Execute') #TODO: Disable button until processing completes or processes are killed
        self.executeButton.pressed.connect(self.initiateProcessing)
        self.killButton = QPushButton('Kill process') #TODO: Disable button until processing is started
        self.killButton.pressed.connect(self.killProcessing)        
        self.textDisplay = QPlainTextEdit()
        self.textDisplay.setReadOnly(True)

        boxLayout = QVBoxLayout()
        boxLayout.addWidget(self.executeButton)
        boxLayout.addWidget(self.killButton)
        boxLayout.addWidget(self.textDisplay)

        theWidget = QWidget()
        theWidget.setLayout(boxLayout)

        self.setCentralWidget(theWidget)
        
    def message(self, mssg):
        self.textDisplay.appendPlainText(mssg)
        
    def initiateProcessing(self):
        print('Processing initiated')
        #---first populate the functions and commands to execute
        self.proc.addThisFunctionToProcessingQueue(self.function1)
        self.proc.addThisFunctionToProcessingQueue(self.function2)
        self.proc.addThisFunctionToProcessingQueue(self.function3)
        self.proc.startTheProcess()
        
    def function1(self, functionReference):
        self.message('Executing function1')
        command = ['python', 'dummyScript1.py']
        self.proc.addThisCommandToProcessingList(command, functionReference)
        
    def function2(self, functionReference):
        self.message('Executing function2')
        
    def function3(self, functionReference):
        self.message('Executing function3')
        maxInvocations = 5
        secondsToSleep = 1
        for _ in range(maxInvocations):
            command = ['python', 'dummyScript2.py', str(secondsToSleep)]
            self.proc.addThisCommandToProcessingList(command, functionReference) 
            secondsToSleep = secondsToSleep + 1

        self.message('Processing complete')
        print('Completed')
    
    def killProcessing(self):
        self.proc.stopTheProcess()

app = QApplication(sys.argv)
w = MainWindow()
w.show()
app.exec_()

Dummy script 1:

#!/usr/bin/python
# -*- coding: ascii -*-
import time
import datetime

sec = 3
print(datetime.datetime.now(), " Going to sleep for ", sec, " seconds")
time.sleep(sec)
print(datetime.datetime.now(), " Done sleeping dummy script1")

Dummy script 2:

#!/usr/bin/python
# -*- coding: ascii -*-
import time

def gotosleep(sleepTime):
    print("Going to sleep for ", sleepTime, " seconds")
    time.sleep(sleepTime)
    print("Done sleeping for ", sleepTime, " seconds in dummy script2")

if __name__ == "__main__":
    FIRST_EXTRA_ARG = 1
    sleepTime = sys.argv[FIRST_EXTRA_ARG]
    gotosleep(sleepTime)

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文