If you are running a WSGI application on top of Tornado, then there isn’t a great deal of difference between Tornado and gunicorn in as much as nothing else can happen so long as the WSGI request is being handled by the application in a specific process. In the case of gunicorn because it only has one thread handling requests and in the case of Tornado because the main event loop will never get to run during that time to handle any concurrent requests.
In the case of Tornado there is actually also a hidden danger.
For gunicorn, because there is only one request thread, the worker process will only accept one web request at a time. If there are concurrent requests hitting the server, they will be handled by any other available worker processes instead as they all share the same listener socket.
With Tornado though, the async nature of the layer under the WSGI application means that more than one request could get accepted at the same time by one process. They will initially interleave as the request headers and content is read, which Tornado pre reads into memory before calling the WSGI application. When the whole request content has then been read, control will be handed off to the WSGI application to handle that one request. In the mean time, the concurrent request being handled by the same process for which the request headers and content hadn’t yet been read, will be blocked for as long as the WSGI application takes to handle the first request.
Now if you only have the one Tornado process this isn’t a big deal as the requests would be serialised anyway, but if you are using tornado worker mode of gunicorn so as to enable multiple Tornado worker processes sharing the same listener sockets this can be quite bad. This is because the greedy nature of individual processes resulting from the async layer, means requests can get blocked in a process when there could have been another worker process which could have handled it.
In summary, for a single Tornado web server process you are stuck with only being able to handle one request at a time. In gunicorn you can have multiple worker process to allow requests to be handle concurrently. Use a multi process setup with Tornado though and you risk having requests blocked.
So Tornado can be quite good for very small custom WSGI application where they don’t do much and so response is very quick, but it can suffer where you have long running requests running under a blocking WSGI application. Gunicorn will therefore be better as it has proper ability to handle concurrent requests. With gunicorn being single threaded though and needing multiple worker processes though, it will use much more memory.
So they both have tradeoffs and in some cases you can be better off using a WSGI server that offers concurrency through multithreading as well as multiple worker processes. This allows you to handle concurrent requests, but not blow out memory usage through needing many worker processes. At the same time, you need to balance the number of threads per process with using multiple processes so as not to suffer unduly from the effects of the GIL in a CPU heavy application.
Choices for WSGI servers with multithreading abilities are mod_wsgi, uWSGI and waitress. For waitress though you are limited to a single worker process.
In all, which is the best WSGI server really depends a lot on the specifics of your web application. There is no one WSGI server which is the best at everything.