Python asyncio: How to control and communicate with subprocesses

Updated: February 12, 2024 By: Guest Contributor Post a comment

Introduction

Python’s asyncio library is a cornerstone of writing efficient and highly concurrent code, especially in scenarios where IO-bound tasks predominate. With recent versions of Python, asyncio has become even more powerful, thanks to numerous enhancements and new features. One of the common use cases where asyncio shines is in the managing subprocesses – external programs that your Python code can start, interact with, and control independently of its main execution flow. In this tutorial, we will explore how to leverage asyncio in Python 3.11 (and higher) to control and communicate with subprocesses, showcasing the simplicity and power of asynchronous programming.

Understanding asyncio Subprocesses

In asyncio, subprocesses are managed by creating and interacting with Process objects. These objects provide a high-level API to asynchronously start, interact with, and terminate subprocesses. This can greatly improve the performance of your application when dealing with IO-bound tasks, such as reading from or writing to standard input and output of child processes, as these operations do not block the execution of your asyncio event loop.

Creating a Subprocess

Let’s start by creating a simple subprocess. We’ll use the asyncio.create_subprocess_exec function, which is designed to instantiate a subprocess executing a specified command. Here’s how you can do it:

import asyncio

async def run_subprocess(cmd):
    process = await asyncio.create_subprocess_exec(
        *cmd,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE
    )

    stdout, stderr = await process.communicate()
    return stdout, stderr

async def main():
    cmd = ['ls', '-l', '/usr/bin']
    stdout, stderr = await run_subprocess(cmd)
    print(f'Standard Output: {stdout.decode()}', f'Standard Error: {stderr.decode()}')

asyncio.run(main())

In this example, we run the ls -l /usr/bin command, a common UNIX command to list directory contents, as a subprocess. The asyncio.create_subprocess_exec function is used to start the process, and its standard output and error are captured asynchronously. This means your Python script can proceed with other tasks while waiting for the subprocess to complete.

Communicating with the Subprocess

Communication with subprocesses involves sending data to their standard input (stdin) and reading from their standard output (stdout) and standard error (stderr). This is where asyncio’s power really shines, allowing for non-blocking communication channels. Here’s an example of how to send inputs to a process and read its outputs:

import asyncio

async def interact_with_process():
    process = await asyncio.create_subprocess_shell(
        'python3 interactive_script.py',
        stdin=asyncio.subprocess.PIPE,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE
    )

    # Sending input to the subprocess.
    process.stdin.write(b'input data\n')
    await process.stdin.drain()

    # Reading subprocess output.
    stdout = await process.stdout.read()
    print(stdout.decode())

    await process.wait()

asyncio.run(interact_with_process())

This example demonstrates how you can start a Python script as a subprocess, send it an input string, and then read the response. This non-blocking approach is perfect for tasks that require interaction with external processes without halting the main program.

Monitoring and Controlling the Subprocess

Monitoring subprocesses to ensure they’re behaving as expected and controlling them (e.g., terminating them) is another crucial aspect of using asyncio with subprocesses. Here’s how you can terminate a subprocess:

import asyncio

async def terminate_process():
    process = await asyncio.create_subprocess_shell('some_long_running_command',
        stdin=asyncio.subprocess.PIPE,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE
    )

    # Optionally, perform operations on the process here.

    process.terminate()
    await process.wait()

asyncio.run(terminate_process())

By calling process.terminate(), you send a termination signal to the subprocess. This is useful for ensuring that tasks do not run indefinitely or for stopping them based on certain conditions detected by your code.

Advanced Usage and Tips

As you become more familiar with using asyncio to manage subprocesses, you’ll discover more advanced techniques and best practices. For instance:

  • Always handle subprocess stdout and stderr asynchronously to prevent deadlocks.
  • Use asyncio.subprocess.create_subprocess_shell judiciously as it introduces a shell injection risk. Prefer create_subprocess_exec for better security.
  • Combine asyncio with other Python features like threading or multiprocessing for handling CPU-bound tasks concurrently with IO-bound tasks.

Conclusion

In summary, Python’s asyncio library offers a robust and efficient way to control and communicate with subprocesses. By integrating these asynchronous capabilities into your Python applications, you can achieve greater concurrency, better performance, and enhanced responsiveness. The examples provided in this guide offer a foundation, but the possibilities are vast and limited only by your imagination. Happy coding!