From 46664cadad9ceff53aaa1f265998b773917c0061 Mon Sep 17 00:00:00 2001 From: Ben Darnell Date: Fri, 3 Jun 2022 15:47:39 -0400 Subject: [PATCH] tcpserver: Deprecate bind/start multi-process This is partially a casualty of the Python 3.10 deprecation changes, although it's also something I've wanted to do for other reasons, since it's been a very common source of user confusion. Fixes #2801 --- docs/guide/running.rst | 31 +++++++-------- tornado/httpserver.py | 62 +++++++++++++++-------------- tornado/tcpserver.py | 71 ++++++++++++++++++++++------------ tornado/test/tcpserver_test.py | 66 ++++++++++++++++++------------- 4 files changed, 134 insertions(+), 96 deletions(-) diff --git a/docs/guide/running.rst b/docs/guide/running.rst index 47db460ad9..5668655244 100644 --- a/docs/guide/running.rst +++ b/docs/guide/running.rst @@ -35,33 +35,28 @@ Due to the Python GIL (Global Interpreter Lock), it is necessary to run multiple Python processes to take full advantage of multi-CPU machines. Typically it is best to run one process per CPU. -.. note:: +The simplest way to do this is to add ``reuse_port=True`` to your ``listen()`` +calls and then simply run multiple copies of your application. - This section is somewhat out of date; the built-in multi-process mode - produces deprecation warnings on Python 3.10 (in addition to its other - limitations). Updated guidance is still in development; tentative - recommendations include running independent processes as described - in the paragraph beginning "For more sophisticated deployments", or - using ``SO_REUSEPORT`` instead of forking. - -Tornado includes a built-in multi-process mode to start several -processes at once (note that multi-process mode does not work on -Windows). This requires a slight alteration to the standard main -function: +Tornado also has the ability to start mulitple processes from a single parent +process (note that this does not work on Windows). This requires some +alterations to application startup. .. testcode:: def main(): - app = make_app() - server = tornado.httpserver.HTTPServer(app) - server.bind(8888) - server.start(0) # forks one process per cpu - IOLoop.current().start() + sockets = bind_sockets(8888) + tornado.process.fork_processes(0) + async def post_fork_main(): + server = TCPServer() + server.add_sockets(sockets) + await asyncio.Event().wait() + asyncio.run(post_fork_main()) .. testoutput:: :hide: -This is the easiest way to start multiple processes and have them all +This is another way to start multiple processes and have them all share the same port, although it has some limitations. First, each child process will have its own ``IOLoop``, so it is important that nothing touches the global ``IOLoop`` instance (even indirectly) before the diff --git a/tornado/httpserver.py b/tornado/httpserver.py index b3e034b9ef..863b11f783 100644 --- a/tornado/httpserver.py +++ b/tornado/httpserver.py @@ -84,46 +84,52 @@ class HTTPServer(TCPServer, Configurable, httputil.HTTPServerConnectionDelegate) `HTTPServer` initialization follows one of three patterns (the initialization methods are defined on `tornado.tcpserver.TCPServer`): - .. note:: + 1. `~tornado.tcpserver.TCPServer.listen`: single-process:: - The multi-process examples here produce deprecation warnings in - Python 3.10; updated guidance is still in development. + async def main(): + server = HTTPServer() + server.listen(8888) + await asyncio.Event.wait() - 1. `~tornado.tcpserver.TCPServer.listen`: simple single-process:: - - server = HTTPServer(app) - server.listen(8888) - IOLoop.current().start() + asyncio.run(main()) In many cases, `tornado.web.Application.listen` can be used to avoid the need to explicitly create the `HTTPServer`. - 2. `~tornado.tcpserver.TCPServer.bind`/`~tornado.tcpserver.TCPServer.start`: - simple multi-process:: + While this example does not create multiple processes on its own, when + the ``reuse_port=True`` argument is passed to ``listen()`` you can run + the program multiple times to create a multi-process service. - server = HTTPServer(app) - server.bind(8888) - server.start(0) # Forks multiple sub-processes - IOLoop.current().start() + 2. `~tornado.tcpserver.TCPServer.add_sockets`: multi-process:: - When using this interface, an `.IOLoop` must *not* be passed - to the `HTTPServer` constructor. `~.TCPServer.start` will always start - the server on the default singleton `.IOLoop`. + sockets = bind_sockets(8888) + tornado.process.fork_processes(0) + async def post_fork_main(): + server = HTTPServer() + server.add_sockets(sockets) + await asyncio.Event().wait() + asyncio.run(post_fork_main()) - 3. `~tornado.tcpserver.TCPServer.add_sockets`: advanced multi-process:: + The `add_sockets` interface is more complicated, but it can be used with + `tornado.process.fork_processes` to run a multi-process service with all + worker processes forked from a single parent. `add_sockets` can also be + used in single-process servers if you want to create your listening + sockets in some way other than `~tornado.netutil.bind_sockets`. - sockets = tornado.netutil.bind_sockets(8888) - tornado.process.fork_processes(0) - server = HTTPServer(app) - server.add_sockets(sockets) + Note that when using this pattern, nothing that touches the event loop + can be run before ``fork_processes``. + + 3. `~tornado.tcpserver.TCPServer.bind`/`~tornado.tcpserver.TCPServer.start`: simple **deprecated** multi-process:: + + server = HTTPServer() + server.bind(8888) + server.start(0) # Forks multiple sub-processes IOLoop.current().start() - The `~.TCPServer.add_sockets` interface is more complicated, - but it can be used with `tornado.process.fork_processes` to - give you more flexibility in when the fork happens. - `~.TCPServer.add_sockets` can also be used in single-process - servers if you want to create your listening sockets in some - way other than `tornado.netutil.bind_sockets`. + This pattern is deprecated because it requires interfaces in the + `asyncio` module that have been deprecated since Python 3.10. Support for + creating multiple processes in the ``start`` method will be removed in a + future version of Tornado. .. versionchanged:: 4.0 Added ``decompress_request``, ``chunk_size``, ``max_header_size``, diff --git a/tornado/tcpserver.py b/tornado/tcpserver.py index 9065b62e89..183aac2177 100644 --- a/tornado/tcpserver.py +++ b/tornado/tcpserver.py @@ -48,14 +48,13 @@ class TCPServer(object): from tornado.tcpserver import TCPServer from tornado.iostream import StreamClosedError - from tornado import gen class EchoServer(TCPServer): async def handle_stream(self, stream, address): while True: try: - data = await stream.read_until(b"\n") - await stream.write(data) + data = await stream.read_until(b"\n") await + stream.write(data) except StreamClosedError: break @@ -71,37 +70,49 @@ async def handle_stream(self, stream, address): `TCPServer` initialization follows one of three patterns: - 1. `listen`: simple single-process:: + 1. `listen`: single-process:: - server = TCPServer() - server.listen(8888) - IOLoop.current().start() + async def main(): + server = TCPServer() + server.listen(8888) + await asyncio.Event.wait() - 2. `bind`/`start`: simple multi-process:: - - server = TCPServer() - server.bind(8888) - server.start(0) # Forks multiple sub-processes - IOLoop.current().start() + asyncio.run(main()) - When using this interface, an `.IOLoop` must *not* be passed - to the `TCPServer` constructor. `start` will always start - the server on the default singleton `.IOLoop`. + While this example does not create multiple processes on its own, when + the ``reuse_port=True`` argument is passed to ``listen()`` you can run + the program multiple times to create a multi-process service. - 3. `add_sockets`: advanced multi-process:: + 2. `add_sockets`: multi-process:: sockets = bind_sockets(8888) tornado.process.fork_processes(0) + async def post_fork_main(): + server = TCPServer() + server.add_sockets(sockets) + await asyncio.Event().wait() + asyncio.run(post_fork_main()) + + The `add_sockets` interface is more complicated, but it can be used with + `tornado.process.fork_processes` to run a multi-process service with all + worker processes forked from a single parent. `add_sockets` can also be + used in single-process servers if you want to create your listening + sockets in some way other than `~tornado.netutil.bind_sockets`. + + Note that when using this pattern, nothing that touches the event loop + can be run before ``fork_processes``. + + 3. `bind`/`start`: simple **deprecated** multi-process:: + server = TCPServer() - server.add_sockets(sockets) + server.bind(8888) + server.start(0) # Forks multiple sub-processes IOLoop.current().start() - The `add_sockets` interface is more complicated, but it can be - used with `tornado.process.fork_processes` to give you more - flexibility in when the fork happens. `add_sockets` can - also be used in single-process servers if you want to create - your listening sockets in some way other than - `~tornado.netutil.bind_sockets`. + This pattern is deprecated because it requires interfaces in the + `asyncio` module that have been deprecated since Python 3.10. Support for + creating multiple processes in the ``start`` method will be removed in a + future version of Tornado. .. versionadded:: 3.1 The ``max_buffer_size`` argument. @@ -232,6 +243,12 @@ def bind( .. versionchanged:: 6.2 Added the ``flags`` argument to match `.bind_sockets`. + + .. deprecated:: 6.2 + Use either ``listen()`` or ``add_sockets()`` instead of ``bind()`` + and ``start()``. The ``bind()/start()`` pattern depends on + interfaces that have been deprecated in Python 3.10 and will be + removed in future versions of Python. """ sockets = bind_sockets( port, @@ -275,6 +292,12 @@ def start( .. versionchanged:: 6.0 Added ``max_restarts`` argument. + + .. deprecated:: 6.2 + Use either ``listen()`` or ``add_sockets()`` instead of ``bind()`` + and ``start()``. The ``bind()/start()`` pattern depends on + interfaces that have been deprecated in Python 3.10 and will be + removed in future versions of Python. """ assert not self._started self._started = True diff --git a/tornado/test/tcpserver_test.py b/tornado/test/tcpserver_test.py index 8b59db577e..c636c8586f 100644 --- a/tornado/test/tcpserver_test.py +++ b/tornado/test/tcpserver_test.py @@ -11,6 +11,8 @@ from tornado.test.util import skipIfNonUnix from tornado.testing import AsyncTestCase, ExpectLog, bind_unused_port, gen_test +from typing import Tuple + class TCPServerTest(AsyncTestCase): @gen_test @@ -121,46 +123,52 @@ class TestMultiprocess(unittest.TestCase): # processes, each of which prints its task id to stdout (a single # byte, so we don't have to worry about atomicity of the shared # stdout stream) and then exits. - def run_subproc(self, code: str) -> str: + def run_subproc(self, code: str) -> Tuple[str, str]: try: result = subprocess.run( - sys.executable, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, + [sys.executable, "-Werror::DeprecationWarning"], + capture_output=True, input=code, encoding="utf8", check=True, ) except subprocess.CalledProcessError as e: raise RuntimeError( - f"Process returned {e.returncode} output={e.stdout}" + f"Process returned {e.returncode} stdout={e.stdout} stderr={e.stderr}" ) from e - return result.stdout + return result.stdout, result.stderr - def test_single(self): + def test_listen_single(self): # As a sanity check, run the single-process version through this test # harness too. code = textwrap.dedent( """ - from tornado.ioloop import IOLoop + import asyncio from tornado.tcpserver import TCPServer - server = TCPServer() - server.listen(0, address='127.0.0.1') - IOLoop.current().run_sync(lambda: None) + async def main(): + server = TCPServer() + server.listen(0, address='127.0.0.1') + + asyncio.run(main()) print('012', end='') """ ) - out = self.run_subproc(code) + out, err = self.run_subproc(code) self.assertEqual("".join(sorted(out)), "012") + self.assertEqual(err, "") - def test_simple(self): + def test_bind_start(self): code = textwrap.dedent( """ + import warnings + from tornado.ioloop import IOLoop from tornado.process import task_id from tornado.tcpserver import TCPServer + warnings.simplefilter("ignore", DeprecationWarning) + server = TCPServer() server.bind(0, address='127.0.0.1') server.start(3) @@ -168,13 +176,14 @@ def test_simple(self): print(task_id(), end='') """ ) - out = self.run_subproc(code) + out, err = self.run_subproc(code) self.assertEqual("".join(sorted(out)), "012") + self.assertEqual(err, "") - def test_advanced(self): + def test_add_sockets(self): code = textwrap.dedent( """ - from tornado.ioloop import IOLoop + import asyncio from tornado.netutil import bind_sockets from tornado.process import fork_processes, task_id from tornado.ioloop import IOLoop @@ -182,20 +191,22 @@ def test_advanced(self): sockets = bind_sockets(0, address='127.0.0.1') fork_processes(3) - server = TCPServer() - server.add_sockets(sockets) - IOLoop.current().run_sync(lambda: None) + async def post_fork_main(): + server = TCPServer() + server.add_sockets(sockets) + asyncio.run(post_fork_main()) print(task_id(), end='') """ ) - out = self.run_subproc(code) + out, err = self.run_subproc(code) self.assertEqual("".join(sorted(out)), "012") + self.assertEqual(err, "") - def test_reuse_port(self): + def test_listen_multi_reuse_port(self): code = textwrap.dedent( """ + import asyncio import socket - from tornado.ioloop import IOLoop from tornado.netutil import bind_sockets from tornado.process import task_id, fork_processes from tornado.tcpserver import TCPServer @@ -206,11 +217,14 @@ def test_reuse_port(self): port = sock.getsockname()[1] fork_processes(3) - server = TCPServer() - server.listen(port, address='127.0.0.1', reuse_port=True) - IOLoop.current().run_sync(lambda: None) + + async def main(): + server = TCPServer() + server.listen(port, address='127.0.0.1', reuse_port=True) + asyncio.run(main()) print(task_id(), end='') """ ) - out = self.run_subproc(code) + out, err = self.run_subproc(code) self.assertEqual("".join(sorted(out)), "012") + self.assertEqual(err, "")