Search code examples
pythonmultithreadingqtpyside6

Why is GUI thread blocked from worker thread when calling waitForReadyRead?


EDIT: I completly restructured the question because I can be much more precise after build up a reprex.

I want to do some synchronous network calls, therefore I created a worker thread and moved the object to the thread.

However when I try to change the Text in the QLineEdit the GUI gets blocked when using the waitForReadyRead in the worker thread. If I use the loop with retries and a smaller timeout for waitForReadyRead the GUI does not get blocked.

As you can see, if I do not connect the textChanged (hence the name of the function) Signal of the QLineEdit everything works fine and I can edit the text field in the GUI. Which afaik means, that as soon as the GUI needs to process events, it gets blocked.

Why is this happening?

If the the GUI thread and worker thread are not executed concurrent my assumption is that the loop with retries would also block for the whole time. At the current state of my knowledge somehow the exectuon of waitForReadyRead blocks both threads or at least the execution of the event loop in the GUI thread.

form.ui:

<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
 <class>MainWindow</class>
 <widget class="QMainWindow" name="MainWindow">
  <property name="geometry">
   <rect>
    <x>0</x>
    <y>0</y>
    <width>1292</width>
    <height>791</height>
   </rect>
  </property>
  <property name="windowTitle">
   <string>MainWindow</string>
  </property>
  <widget class="QWidget" name="centralwidget">
   <layout class="QVBoxLayout" name="verticalLayout_2">
    <item>
     <layout class="QHBoxLayout" name="horizontalLayout_4">
      <item>
       <layout class="QVBoxLayout" name="verticalLayout">
        <item>
         <layout class="QHBoxLayout" name="horizontalLayout">
          <item>
           <widget class="QLabel" name="label">
            <property name="text">
             <string>Textfield</string>
            </property>
           </widget>
          </item>
          <item>
           <widget class="QLineEdit" name="line_edit">
            <property name="text">
             <string>Some Text</string>
            </property>
           </widget>
          </item>
         </layout>
        </item>
       </layout>
      </item>
     </layout>
    </item>
    <item>
     <layout class="QHBoxLayout" name="horizontalLayout_5">
      <item>
       <widget class="QPushButton" name="btn_btn">
        <property name="enabled">
         <bool>true</bool>
        </property>
        <property name="text">
         <string>Button</string>
        </property>
        <property name="checkable">
         <bool>false</bool>
        </property>
       </widget>
      </item>
     </layout>
    </item>
   </layout>
  </widget>
  <widget class="QMenuBar" name="menubar">
   <property name="geometry">
    <rect>
     <x>0</x>
     <y>0</y>
     <width>1292</width>
     <height>22</height>
    </rect>
   </property>
  </widget>
  <widget class="QStatusBar" name="statusbar"/>
 </widget>
 <resources/>
 <connections/>
</ui>

main.py:

# This Python file uses the following encoding: utf-8
import os
import sys
from PySide6.QtCore import QFile, QObject, QThread, Slot
from PySide6.QtUiTools import QUiLoader
from PySide6.QtWidgets import QApplication, QMainWindow
from pathlib import Path
from PySide6.QtNetwork import QTcpSocket


class WorkerClass(QObject):
    def __init__(self):
        super().__init__()

    @Slot()
    def do_work(self):
        print("Worker Thread: " + str(QThread.currentThread()))

        self._socket = QTcpSocket()
        self._socket.connectToHost("example.com", 80)
        if self._socket.waitForConnected(5000):
            print("Connected")
        else:
            print("Not Connected")

        # none blocking ui
        # retries = 1000
        # while retries:
        #     retries -= 1
        #     if self._socket.waitForReadyRead(50):
        #         answer = self._socket.readAll()
        #         break
        #     elif retries == 0:
        #         print("Timeout")
        
        # blocking ui for 10 seconds
        if self._socket.waitForReadyRead(10000):
            print("Answer received")
        else:
            print("Timeout")


class MainWindow(QMainWindow):
    def __init__(self):
        super(MainWindow, self).__init__()
        self.__load_ui()
        self.ui.btn_btn.clicked.connect(self.start_worker)
        self.ui.line_edit.textChanged.connect(self.why_blocks_this_connection)

    def __load_ui(self):
        loader = QUiLoader()
        path = os.fspath(Path(__file__).resolve().parent / "form.ui")
        ui_file = QFile(path)
        ui_file.open(QFile.ReadOnly)
        self.ui = loader.load(ui_file, self)
        ui_file.close()

    def show(self):
        self.ui.show()

    @Slot()
    def start_worker(self):
        print("GUI Thread: " + str(QThread.currentThread()))
        self._worker = WorkerClass()
        self._network_thread = QThread()
        self._network_thread.started.connect(self._worker.do_work)
        self._worker.moveToThread(self._network_thread)
        self._network_thread.start()

    def why_blocks_this_connection(self, new_val):
        print(new_val)

if __name__ == "__main__":
    app = QApplication([])
    widget = MainWindow()
    widget.show()
    sys.exit(app.exec())

Solution

  • Explanation

    There seems to be a bug with PySide6 (and also PySide2) that causes the waitForReadyRead method to block the main thread (or the main eventloop) causing this unexpected behavior. In PyQt it works correctly.

    Workaround

    In this case a possible solution is to use asyncio through qasync:

    import asyncio
    import os
    import sys
    from pathlib import Path
    
    from PySide6.QtCore import QFile, QIODevice, QObject, Slot
    from PySide6.QtWidgets import QApplication
    from PySide6.QtUiTools import QUiLoader
    
    import qasync
    
    CURRENT_DIRECTORY = Path(__file__).resolve().parent
    
    
    class Worker(QObject):
        async def do_work(self):
            try:
                reader, writer = await asyncio.wait_for(
                    asyncio.open_connection("example.com", 80), timeout=5.0
                )
            except Exception as e:
                print("Not Connected")
                return
            print("Connected")
            # writer.write(b"Hello World!")
            try:
                data = await asyncio.wait_for(reader.read(), timeout=10.0)
            except Exception as e:
                print("Timeout")
                return
            print("Answer received")
            print(data)
    
    
    class WindowManager(QObject):
        def __init__(self, parent=None):
            super().__init__(parent)
            self.ui = None
            self.__load_ui()
            if self.ui is not None:
                self.ui.btn_btn.clicked.connect(self.start_worker)
                self.ui.line_edit.textChanged.connect(self.why_blocks_this_connection)
    
        def __load_ui(self):
            loader = QUiLoader()
            path = os.fspath(CURRENT_DIRECTORY / "form.ui")
            ui_file = QFile(path)
            ui_file.open(QIODevice.ReadOnly)
            self.ui = loader.load(ui_file)
            ui_file.close()
    
        def show(self):
            self.ui.show()
    
        @Slot()
        def start_worker(self):
            self.worker = Worker()
            asyncio.ensure_future(self.worker.do_work())
    
        def why_blocks_this_connection(self, new_val):
            print(new_val)
    
    
    def main():
        app = QApplication(sys.argv)
        loop = qasync.QEventLoop(app)
        asyncio.set_event_loop(loop)
    
        w = WindowManager()
        w.show()
    
        with loop:
            loop.run_forever()
    
    
    if __name__ == "__main__":
        main()