You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

为何子进程退出后asyncio.run仍无法返回?

问题描述

通过pkill ssh杀死SSH子进程后,Python程序的asyncio.run无法正常退出。虽然所有异步协程(run_command、read_from_process、write_to_process)均已执行完毕,日志输出如下:

finally... worker 1 done
finally... worker 2 done
t1, t2 done
finally
finally .. DONE  returncode=255

但终端始终未打印ALL DONE,用ps命令查看能确认Python进程还在运行。相关代码如下:

import asyncio
import os
import pty
import sys
import termios
import traceback
import tty

async def run_command(command):
    # Create a pseudoterminal pair (master and slave)
    master_fd, slave_fd = pty.openpty()
    old_tty = termios.tcgetattr(sys.stdin)

    try:
        # Set the terminal in raw mode for capturing control characters
        tty.setraw(sys.stdin.fileno())
        # Start the process using the slave as stdin, stdout, stderr
        process = await asyncio.create_subprocess_exec(
            *command,
            stdin=slave_fd,
            stdout=slave_fd,
            stderr=slave_fd
        )

        # Close the slave in the parent process
        os.close(slave_fd)


        # Start tasks to handle reading from and writing to the terminal
        async def onCancel(coro, proc):
            try:
                await coro
            except asyncio.CancelledError as e:
                termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
            except OSError as e:
                termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
                # Cancel other end.
                if proc == 1:
                    t2.cancel()
                else:
                    t1.cancel()
                # process.kill()

                # print("".join(traceback.format_exception(type(e), e, e.__traceback__)), file=sys.stderr)
                # cancel_task(t1)
                # cancel_task(t2)
                # print("".join(traceback.format_exception(type(e), e, e.__traceback__)), file=sys.stderr)
            except Exception as e:
                termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
                print("".join(traceback.format_exception(type(e), e, e.__traceback__)), file=sys.stderr)
            finally:
                termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
                print(f"finally... worker {proc} done")
                # cancel_task(t1)
                # cancel_task(t2)

        t1 = asyncio.create_task(onCancel(read_from_process(master_fd), 1))
        t2 = asyncio.create_task(onCancel(write_to_process(master_fd), 2))

        await asyncio.gather(t1, t2)
        print("t1, t2 done")
        await process.wait()

        # asyncUtil.printWorkers() # Shows only run_command coro remains

    finally:
        print("finally")
        # Restore the terminal to its previous state
        os.close(master_fd)
        termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
        print(f"finally .. DONE  returncode={process.returncode}")


async def read_from_process(master_fd):
    """Asynchronously read from the master side of the PTY and print to stdout. Note that stdin/stdout are combined."""
    loop = asyncio.get_event_loop()
    while True:
        data = await loop.run_in_executor(None, os.read, master_fd, 1024)
        if not data:  # EOF
            break
        sys.stdout.write(data.decode())
        sys.stdout.flush()


async def write_to_process(master_fd):
    """Asynchronously read from stdin and send input to the process."""
    try:
        loop = asyncio.get_event_loop()
        while True:
            try:
                user_input = await loop.run_in_executor(None, sys.stdin.read, 1)  # Read one character
                if user_input == "\x04":  # CTRL+D to signal EOF
                    os.write(master_fd, user_input.encode())
                    # print("CTRL+D")
                    break
                if user_input == "":
                    break  # EOF, such as CTRL-D
                os.write(master_fd, user_input.encode())
            except EOFError:
                break
    except Exception as e:
        print("".join(traceback.format_exception(type(e), e, e.__traceback__)), file=sys.stderr)


# Run the main event loop with the command
command = ["ssh", "localhost"]  # replace with your SSH command
asyncio.run(run_command(command))
print("ALL DONE")
sys.stdout.flush()
原因分析

核心问题出在阻塞的非守护线程上:

  • write_to_process协程通过asyncio.run_in_executor启动了一个线程,专门执行sys.stdin.read(1)读取用户输入。这个线程属于非守护线程,只要它还在运行,Python进程就不会主动退出。
  • 当SSH进程被杀死后,这个读取线程会一直阻塞等待用户输入,不会自动终止。哪怕所有协程的逻辑都执行完毕,后台残留的这个线程会卡住整个进程。
解决方案

这里提供两种简单有效的修复方式:

方案一:SSH退出后主动取消输入任务

在确认SSH进程退出后,主动取消write_to_process对应的任务,强制中断stdin读取线程的阻塞:
修改run_command函数中await process.wait()之后的代码,添加任务取消逻辑:

await process.wait()
# 主动取消输入任务,终止stdin读取线程
if t2:
    t2.cancel()
    try:
        await t2
    except asyncio.CancelledError:
        pass

方案二:改用异步IO读取stdin

放弃用线程池阻塞读取stdin,改用asyncio.StreamReader实现异步读取,这样协程可以被正常取消,不会残留阻塞线程。修改write_to_process函数如下:

async def write_to_process(master_fd):
    """Asynchronously read from stdin and send input to the process."""
    loop = asyncio.get_event_loop()
    # 创建StreamReader异步读取stdin
    reader = asyncio.StreamReader()
    protocol = asyncio.StreamReaderProtocol(reader)
    await loop.connect_read_pipe(lambda: protocol, sys.stdin)
    
    try:
        while True:
            # 异步读取一个字节
            user_input = await reader.read(1)
            if not user_input:  # 读到EOF
                break
            user_input_str = user_input.decode()
            if user_input_str == "\x04":  # 捕获CTRL+D
                os.write(master_fd, user_input)
                break
            os.write(master_fd, user_input)
    except Exception as e:
        print("".join(traceback.format_exception(type(e), e, e.__traceback__)), file=sys.stderr)
修改后的完整代码(方案一)
import asyncio
import os
import pty
import sys
import termios
import traceback
import tty

async def run_command(command):
    master_fd, slave_fd = pty.openpty()
    old_tty = termios.tcgetattr(sys.stdin)
    process = None
    t1 = None
    t2 = None

    try:
        tty.setraw(sys.stdin.fileno())
        process = await asyncio.create_subprocess_exec(
            *command,
            stdin=slave_fd,
            stdout=slave_fd,
            stderr=slave_fd
        )
        os.close(slave_fd)

        async def onCancel(coro, proc):
            nonlocal t1, t2
            try:
                await coro
            except asyncio.CancelledError:
                termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
            except OSError:
                termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
                if proc == 1 and t2:
                    t2.cancel()
                elif proc == 2 and t1:
                    t1.cancel()
            except Exception as e:
                termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
                print("".join(traceback.format_exception(type(e), e, e.__traceback__)), file=sys.stderr)
            finally:
                termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
                print(f"finally... worker {proc} done")

        t1 = asyncio.create_task(onCancel(read_from_process(master_fd), 1))
        t2 = asyncio.create_task(onCancel(write_to_process(master_fd), 2))

        await asyncio.gather(t1, t2)
        print("t1, t2 done")
        await process.wait()

        # 主动取消输入任务,终止stdin读取线程
        if t2:
            t2.cancel()
            try:
                await t2
            except asyncio.CancelledError:
                pass

    finally:
        print("finally")
        if master_fd:
            os.close(master_fd)
        termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
        if process:
            print(f"finally .. DONE  returncode={process.returncode}")


async def read_from_process(master_fd):
    loop = asyncio.get_event_loop()
    while True:
        try:
            data = await loop.run_in_executor(None, os.read, master_fd, 1024)
            if not data:
                break
            sys.stdout.write(data.decode())
            sys.stdout.flush()
        except OSError:
            break


async def write_to_process(master_fd):
    try:
        loop = asyncio.get_event_loop()
        while True:
            try:
                user_input = await loop.run_in_executor(None, sys.stdin.read, 1)
                if user_input == "\x04":
                    os.write(master_fd, user_input.encode())
                    break
                if user_input == "":
                    break
                os.write(master_fd, user_input.encode())
            except EOFError:
                break
    except Exception as e:
        print("".join(traceback.format_exception(type(e), e, e.__traceback__)), file=sys.stderr)


command = ["ssh", "localhost"]
asyncio.run(run_command(command))
print("ALL DONE")
sys.stdout.flush()

内容的提问来源于stack exchange,提问作者user48956

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.16 17:55:54