Search code examples
pythonsocketstcppython-asyncio

how to use asyncio.serve_forever() without freezing GUI?


I am trying to create GUI with pyqt5 for tcp communication. I have 2 different .py file one for GUI and one for TCP. tcp.py contains tcpClients and tcpServer classes which has a function called tcp_server_connect (can be found below) to create connection between client and server. GUI has a button called connect and i want to call the function tcp_server_connect when it is pressed. When I press it GUI freezes and not respond. There is no error. I think it is because of server.serve_forever() loop but I am not sure how to solve this problem.

async def tcp_server_connect():
    server = await asyncio.start_server(
        handle_client, '127.0.0.1', 8888)
    async with server:
        await server.serve_forever()

Solution

  • The issue is that Qt runs on the main thread, and trying to run an asyncio event loop on the same thread won't work. Both Qt and the event loop want to and need to control the entire thread. As a note, Qt did recently announce an asyncio module for Python, but that is still in technical preview.

    The below example, which uses PySide6 (as I have that already installed), has a Qt window with buttons to start a TCP server and then a string entry control and a button to send the message to the TCP server. There is an asyncio event loop running on a separate thread that listens to messages and then performs the required actions. Try it out. All you need to install is PySide6.

    # Core dependencies
    import asyncio
    import sys
    from threading import Thread
    
    # Package dependencies
    from PySide6.QtWidgets import (
        QApplication,
        QWidget,
        QPushButton,
        QVBoxLayout,
        QLineEdit,
    )
    
    
    class MainWindow(QWidget):
        def __init__(self) -> None:
            super().__init__()
    
            # The the `asyncio` queue and event loop are created here, in the GUI thread (main thread),
            # but they will be passed into a new thread that will actually run the event loop.
            # Under no circumstances should the `asyncio.Queue` be used outside of that event loop. It
            # is only okay to construct it outside of the event loop.
            self._async_queue = asyncio.Queue()
            self._asyncio_event_loop = asyncio.new_event_loop()
    
            self.initialize()
    
        def initialize(self) -> None:
            """Initialize the GUI widgets"""
    
            self.setWindowTitle("PySide with asyncio server and client")
    
            # Create layout
            main_layout = QVBoxLayout()
            self.setLayout(main_layout)
    
            button_start_server = QPushButton(text="Start server")
            line_edit_message = QLineEdit()
            button_send_message = QPushButton(text="Send message")
    
            main_layout.addWidget(button_start_server)
            main_layout.addWidget(line_edit_message)
            main_layout.addWidget(button_send_message)
    
            button_start_server.pressed.connect(
                lambda: self.send_message_to_event_loop("start_server")
            )
            button_send_message.pressed.connect(
                lambda: self.send_message_to_event_loop(line_edit_message.text())
            )
    
            # Disable the user being able to resize the window by setting a fixed size
            self.setFixedWidth(500)
            self.setFixedHeight(200)
    
            # Show the window
            self.show()
    
        def send_message_to_event_loop(self, message: str) -> None:
            """Send the `asyncio` event loop's queue a message by using the coroutine
            `put` and sending it to run on the `asyncio` event loop, putting the message
            on the queue inside the event loop. This must be done because `asyncio.Queue`
            is not threadsafe.
            """
            asyncio.run_coroutine_threadsafe(
                coro=self._async_queue.put(message),
                loop=self._asyncio_event_loop,
            )
    
    
    async def handle_client(
        reader: asyncio.StreamReader, writer: asyncio.StreamWriter
    ) -> None:
        data = await reader.read(100)
        message = data.decode()
        addr = writer.get_extra_info("peername")
    
        print(f"Received {message!r} from {addr!r}")
    
        writer.close()
        await writer.wait_closed()
    
    
    async def run_server():
        server = await asyncio.start_server(handle_client, "127.0.0.1", 8888)
        async with server:
            print("Started server")
            await server.serve_forever()
    
    
    async def send_message_to_server(message: str) -> None:
        # Lazily create a new connection every time just for demonstration
        # purposes
        _, writer = await asyncio.open_connection("127.0.0.1", 8888)
    
        writer.write(message.encode())
        await writer.drain()
    
        writer.close()
        await writer.wait_closed()
    
    
    async def read_messages(queue: asyncio.Queue) -> None:
        server_task = None
        while True:
            message = await queue.get()
            match message:
                case "start_server":
                    server_task = asyncio.create_task(run_server())
                case msg:
                    await send_message_to_server(msg)
    
    
    async def async_main(queue: asyncio.Queue):
        # Launch the tasks to sit around and listen to messages
        await asyncio.gather(read_messages(queue))
    
    
    def start_asyncio_event_loop(loop: asyncio.AbstractEventLoop) -> None:
        """Starts the given `asyncio` loop on whatever the current thread is"""
        asyncio.set_event_loop(loop)
        loop.set_debug(enabled=True)
        loop.run_forever()
    
    
    def run_event_loop(queue: asyncio.Queue, loop: asyncio.AbstractEventLoop) -> None:
        """Runs the given `asyncio` loop on a separate thread, passing the queue to the
        event loop for any other thread to send messages to the event loop. The main
        coroutine that is launched on the event loop is `async_main`.
        """
        thread = Thread(target=start_asyncio_event_loop, args=(loop,), daemon=True)
        thread.start()
    
        asyncio.run_coroutine_threadsafe(async_main(queue), loop=loop)
    
    
    def run_application(application: QApplication):
        application.exec()
    
    
    if __name__ == "__main__":
        application = QApplication(sys.argv)
        window = MainWindow()
        async_queue = window._async_queue
        asyncio_event_loop = window._asyncio_event_loop
    
        run_event_loop(queue=async_queue, loop=asyncio_event_loop)
        sys.exit(run_application(application))