Skip to content

Commit

Permalink
tcpserver: Deprecate bind/start multi-process
Browse files Browse the repository at this point in the history
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 tornadoweb#2801
  • Loading branch information
bdarnell committed Jun 3, 2022
1 parent 8ebbfea commit 46664ca
Show file tree
Hide file tree
Showing 4 changed files with 134 additions and 96 deletions.
31 changes: 13 additions & 18 deletions docs/guide/running.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 34 additions & 28 deletions tornado/httpserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``,
Expand Down
71 changes: 47 additions & 24 deletions tornado/tcpserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
66 changes: 40 additions & 26 deletions tornado/test/tcpserver_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -121,81 +123,90 @@ 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)
IOLoop.current().run_sync(lambda: None)
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
from tornado.tcpserver import TCPServer
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
Expand All @@ -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, "")

0 comments on commit 46664ca

Please sign in to comment.