繁体   English   中英

使用 asyncio 的简单 Python TCP 分叉服务器

[英]Simple Python TCP forking server using asyncio

我想做什么

我正在尝试模拟以下简单socat(1)命令的行为:

socat tcp-listen:SOME_PORT,fork,reuseaddr exec:'SOME_PROGRAM'

上面的命令创建了一个分叉 TCP 服务器,它为每个连接分叉并执行SOME_PROGRAM ,将所述命令的stdinstdout重定向到 TCP 套接字。

这是我想要实现的目标

  1. 使用asyncio创建一个简单的 TCP 服务器来处理多个并发连接。
  2. 每当接收到连接时,将SOME_PROGRAM作为子进程启动。
  3. 将从套接字接收到的任何数据传递到SOME_PROGRAM的标准输入。
  4. 将从SOME_PROGRAM的标准输出接收到的任何数据传递到套接字。
  5. SOME_PROGRAM退出时,将告别消息和退出代码一起写入套接字并关闭连接。

我想在纯 Python 中执行此操作,而不使用使用asyncio模块的外部库。

到目前为止我所拥有的

这是我到目前为止编写的代码(如果它很长,请不要害怕,只是大量注释和间隔):

import asyncio

class ServerProtocol(asyncio.Protocol):
    def connection_made(self, transport):
        self.client_addr   = transport.get_extra_info('peername')
        self.transport     = transport
        self.child_process = None

        print('Connection with {} enstablished'.format(self.client_addr))

        asyncio.ensure_future(self._create_subprocess())

    def connection_lost(self, exception):
        print('Connection with {} closed.'.format(self.client_addr))

        if self.child_process.returncode is not None:
            self.child_process.terminate()

    def data_received(self, data):
        print('Data received: {!r}'.format(data))

        # Make sure the process has been spawned
        # Does this even make sense? Looks so awkward to me...
        while self.child_process is None:
            continue

        # Write any received data to child_process' stdin
        self.child_process.stdin.write(data)

    async def _create_subprocess(self):
        self.child_process = await asyncio.create_subprocess_exec(
            *TARGET_PROGRAM,
            stdin=asyncio.subprocess.PIPE,
            stdout=asyncio.subprocess.PIPE
        )

        # Start reading child stdout
        asyncio.ensure_future(self._pipe_child_stdout())

        # Ideally I would register some callback here so that when
        # child_process exits I can write to the socket a goodbye
        # message and close the connection, but I don't know how
        # I could do that...

    async def _pipe_child_stdout(self):
        # This does not seem to work, this function returns b'', that is an
        # empty buffer, AFTER the process exits...
        data = await self.child_process.stdout.read(100) # Arbitrary buffer size

        print('Child process data: {!r}'.format(data))

        if data:
            # Send to socket
            self.transport.write(data)
            # Reschedule to read more data
            asyncio.ensure_future(self._pipe_child_stdout())


SERVER_PORT    = 6666
TARGET_PROGRAM = ['./test']

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    coro = loop.create_server(ServerProtocol, '0.0.0.0', SERVER_PORT)
    server = loop.run_until_complete(coro)

    print('Serving on {}'.format(server.sockets[0].getsockname()))

    try:
        loop.run_forever()
    except KeyboardInterrupt:
        pass

    server.close()
    loop.run_until_complete(server.wait_closed())
    loop.close()

还有我试图作为子./test运行的./test程序:

#!/usr/bin/env python3

import sys

if sys.stdin.read(2) == 'a\n':
    sys.stdout.write('Good!\n')
else:
    sys.exit(1)

if sys.stdin.read(2) == 'b\n':
    sys.stdout.write('Wonderful!\n')
else:
    sys.exit(1)

sys.exit(0)

不幸的是,上面的代码并没有真正起作用,我对接下来要尝试的东西有点迷茫。

什么按预期工作

  • 子进程正确生成,并且似乎也正确地接收了来自套接字的输入,因为我可以从htop看到它,而且我也可以看到,一旦我发送b\\n它就会终止。

什么不按预期工作

其他的基本上都...

  • 子进程的输出永远不会发送到套接字,实际上根本不会读取。 调用await self.child_process.stdout.read(100)似乎永远不会终止:相反,它仅子进程死亡并且结果只是b'' (一个空bytes对象)后才终止。
  • 我无法理解子进程何时终止:如上所述,我想在发生这种情况时向套接字发送“再见”消息以及self.child_process.returncode ,但我不知道如何以一种有意义的方式做到这一点。

我试过的

问题

那么,有人可以帮我弄清楚我做错了什么吗? 必须有办法使这项工作顺利进行。 当我第一次开始时,我正在寻找一种方法来轻松使用一些管道重定向,但我不知道此时是否可能。 是吗? 看起来应该是这样。

我可以在 15 分钟内使用fork()exec()dup2()用 C 编写这个程序,所以我一定缺少一些东西! 任何帮助表示赞赏。

您的代码有两个直接的实现问题:

  • 服务器在将接收到的数据传输到子进程之前去除空白。 这会删除尾部的换行符,因此如果 TCP 客户端发送"a\\n" ,子进程将只收到"a" 这样子进程永远不会遇到预期的"a\\n"字符串,并且它总是在读取两个字节后终止。 这解释了来自子流程的空字符串 (EOF)。 (剥离已在对该问题的后续编辑中删除。)
  • 子进程不会刷新其输出,因此服务器不会收到任何写入。 只有在子进程退出或填满其输出缓冲区时才会看到写入,该缓冲区以千字节为单位,在显示简短的调试消息时需要一段时间来填充。

另一个问题是在设计层面。 如评论中所述,除非您明确打算实现新的 asyncio 协议,否则建议坚持使用更高级别的基于流的 API ,在本例中为start_server函数。 甚至更低级别的功能,如SubprocessProtocolconnect_write_pipeconnect_read_pipe也不是您想要在应用程序代码中使用的东西。 这个答案的其余部分假设一个基于流的实现。

start_server接受一个协程,该协程将在客户端连接时作为新任务产生。 它使用两个异步流参数调用,一个用于读取,一个用于写入。 协程包含与客户端通信的逻辑; 在你的情况下,它会产生子进程并在它和客户端之间传输数据。

请注意,套接字和子进程之间的双向数据传输无法通过读取后写入的简单循环来实现。 例如,考虑这个循环:

# INCORRECT: can deadlock (and also doesn't detect EOF)
child = await asyncio.create_subprocess_exec(...)
while True:
    proc_data = await child.stdout.read(1024)  # (1)
    sock_writer.write(proc_data)
    sock_data = await sock_reader.read(1024)
    child.stdin.write(sock_data)               # (2)

这种循环容易出现死锁。 如果子进程正在响应它从 TCP 客户端接收的数据,它有时只会在接收到一些输入后才提供输出。 这将无限期地阻塞 (1) 处的循环,因为它只能在将sock_data发送给孩子之后才能从孩子的stdout获取数据,这将在 (2) 处发生。 实际上,(1)等待(2),反之亦然,构成一个死锁。 请注意,颠倒传输顺序无济于事,因为如果 TCP 客户端正在处理服务器子进程的输出,那么循环就会死锁。

使用 asyncio 可以轻松避免这种死锁:只需并行生成两个协程,一个将数据从套接字传输到子进程的标准输入,另一个将数据从子进程的标准输出传输到套接字。 例如:

# correct: deadlock-free (and detects EOF)
async def _transfer(src, dest):
    while True:
        data = await src.read(1024)
        if data == b'':
            break
        dest.write(data)

child = await asyncio.create_subprocess_exec(...)
loop.create_task(_transfer(child.stdout, sock_writer))
loop.create_task(_transfer(sock_reader, child.stdin))
await child.wait()

此设置与第一个while循环之间的区别在于两个传输相互独立。 死锁不会发生,因为从套接字读取从不等待从子进程读取,反之亦然。

应用于这个问题,整个服务器看起来像这样:

import asyncio

class ProcServer:
    async def _transfer(self, src, dest):
        while True:
            data = await src.read(1024)
            if data == b'':
                break
            dest.write(data)

    async def _handle_client(self, r, w):
        loop = asyncio.get_event_loop()
        print(f'Connection from {w.get_extra_info("peername")}')
        child = await asyncio.create_subprocess_exec(
            *TARGET_PROGRAM, stdin=asyncio.subprocess.PIPE,
            stdout=asyncio.subprocess.PIPE)
        sock_to_child = loop.create_task(self._transfer(r, child.stdin))
        child_to_sock = loop.create_task(self._transfer(child.stdout, w))
        await child.wait()
        sock_to_child.cancel()
        child_to_sock.cancel()
        w.write(b'Process exited with status %d\n' % child.returncode)
        w.close()

    async def start_serving(self):
        await asyncio.start_server(self._handle_client,
                                   '0.0.0.0', SERVER_PORT)

SERVER_PORT    = 6666
TARGET_PROGRAM = ['./test']

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    server = ProcServer()
    loop.run_until_complete(server.start_serving())
    loop.run_forever()

所附的test程序还必须修改为呼叫sys.stdout.flush()每之后sys.stdout.write()否则留连在其标准输入输出缓冲器,而不是被发送到父的消息。

当我第一次开始时,我正在寻找一种方法来轻松使用一些管道重定向,但我不知道此时是否可能。 是吗? 看起来应该是这样。

在类 Unix 系统上,当然可以将套接字重定向到生成的子进程,以便子进程直接与客户端对话。 (旧的inetd Unix 服务器是这样工作的。)但是 asyncio 不支持这种操作模式,原因有两个:

  • 它不适用于 Python 和 asyncio 支持的所有系统,尤其是在 Windows 上。
  • 它与核心 asyncio 功能不兼容,例如传输/协议和流,它们承担对底层套接字的所有权和独占访问。

即使你不关心可移植性,请考虑第二点:你可能需要处理或记录 TCP 客户端和子进程之间交换的数据,如果它们在内核级别焊接在一起,你就不能这样做. 此外,与仅处理不透明子进程相比,在 asyncio 协程中实现超时和取消要容易得多。

如果不可移植性和无法控制通信适合您的用例,那么您可能一开始就不需要 asyncio - 没有什么可以阻止您生成一个运行经典阻塞服务器的线程,该服务器使用相同的方式处理每个客户端os.forkos.dup2os.execlp ,你会用 C 编写。

编辑

正如 OP 在评论中指出的那样,原始代码通过杀死子进程来处理 TCP 客户端断开连接。 在流层,连接丢失由流反映,要么发出文件结束信号,要么引发异常。 在上面的代码中,可以通过用处理这种情况的更具体的协程替换通用self._transfer()来轻松应对连接丢失。 例如,而不是:

sock_to_child = loop.create_task(self._transfer(r, child.stdin))

...可以写:

sock_to_child = loop.create_task(self._sock_to_child(r, child))

并像这样定义_sock_to_child (未经测试):

async def _sock_to_child(self, reader, child):
    try:
        await self._transfer(reader, child.stdin)
    except IOError as e:
        # IO errors are an expected part of the workflow,
        # we don't want to propagate them
        print('exception:', e)
    child.kill()

如果子进程比 TCP 客户端child.kill() ,则child.kill()行可能永远不会执行,因为协程将被_handle_client取消,同时暂停在src.read()内的_transfer()

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM