Server: Run the actual request handling code in threads so as not to block the servers event loop

This commit is contained in:
Kovid Goyal 2015-05-29 10:35:31 +05:30
parent 5b2049c3c8
commit ab81fe992b
6 changed files with 198 additions and 35 deletions

View File

@ -9,3 +9,6 @@ __copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
class HTTP404(Exception): class HTTP404(Exception):
pass pass
class JobQueueFull(Exception):
pass

View File

@ -316,14 +316,45 @@ class HTTPConnection(HTTPRequest):
outheaders, self.response_protocol, self.static_cache, self.opts, outheaders, self.response_protocol, self.static_cache, self.opts,
self.remote_addr, self.remote_port self.remote_addr, self.remote_port
) )
try: self.queue_job(self.run_request_handler, data)
output = self.request_handler(data)
except HTTP404 as e:
return self.simple_response(httplib.NOT_FOUND, msg=e.message or '', close_after_response=False)
def run_request_handler(self, data):
result = self.request_handler(data)
return data, result
def send_range_not_satisfiable(self, content_length):
buf = [
'%s %d %s' % (self.response_protocol, httplib.REQUESTED_RANGE_NOT_SATISFIABLE, httplib.responses[httplib.REQUESTED_RANGE_NOT_SATISFIABLE]),
"Date: " + http_date(),
"Content-Range: bytes */%d" % content_length,
]
self.response_ready(header_list_to_file(buf))
def send_not_modified(self, etag=None):
buf = [
'%s %d %s' % (self.response_protocol, httplib.NOT_MODIFIED, httplib.responses[httplib.NOT_MODIFIED]),
"Content-Length: 0",
"Date: " + http_date(),
]
if etag is not None:
buf.append('ETag: ' + etag)
self.response_ready(header_list_to_file(buf))
def report_busy(self):
self.simple_response(httplib.SERVICE_UNAVAILABLE)
def job_done(self, ok, result):
if not ok:
etype, e, tb = result
if isinstance(e, HTTP404):
return self.simple_response(httplib.NOT_FOUND, msg=e.message or '', close_after_response=False)
raise e, None, tb
data, output = result
output = self.finalize_output(output, data, self.method is HTTP1) output = self.finalize_output(output, data, self.method is HTTP1)
if output is None: if output is None:
return return
outheaders = data.outheaders
outheaders.set('Date', http_date(), replace_all=True) outheaders.set('Date', http_date(), replace_all=True)
outheaders.set('Server', 'calibre %s' % __version__, replace_all=True) outheaders.set('Server', 'calibre %s' % __version__, replace_all=True)
@ -348,24 +379,6 @@ class HTTPConnection(HTTPRequest):
buf.append('') buf.append('')
self.response_ready(BytesIO(b''.join((x + '\r\n').encode('ascii') for x in buf)), output=output) self.response_ready(BytesIO(b''.join((x + '\r\n').encode('ascii') for x in buf)), output=output)
def send_range_not_satisfiable(self, content_length):
buf = [
'%s %d %s' % (self.response_protocol, httplib.REQUESTED_RANGE_NOT_SATISFIABLE, httplib.responses[httplib.REQUESTED_RANGE_NOT_SATISFIABLE]),
"Date: " + http_date(),
"Content-Range: bytes */%d" % content_length,
]
self.response_ready(header_list_to_file(buf))
def send_not_modified(self, etag=None):
buf = [
'%s %d %s' % (self.response_protocol, httplib.NOT_MODIFIED, httplib.responses[httplib.NOT_MODIFIED]),
"Content-Length: 0",
"Date: " + http_date(),
]
if etag is not None:
buf.append('ETag: ' + etag)
self.response_ready(header_list_to_file(buf))
def response_ready(self, header_file, output=None): def response_ready(self, header_file, output=None):
self.response_started = True self.response_started = True
self.optimize_for_sending_packet() self.optimize_for_sending_packet()

View File

@ -8,10 +8,13 @@ __copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
import ssl, socket, select, os, traceback import ssl, socket, select, os, traceback
from io import BytesIO from io import BytesIO
from Queue import Empty, Full
from functools import partial from functools import partial
from calibre import as_unicode from calibre import as_unicode
from calibre.ptempfile import TemporaryDirectory from calibre.ptempfile import TemporaryDirectory
from calibre.srv.errors import JobQueueFull
from calibre.srv.pool import ThreadPool
from calibre.srv.opts import Options from calibre.srv.opts import Options
from calibre.srv.utils import ( from calibre.srv.utils import (
socket_errors_socket_closed, socket_errors_nonblocking, HandleInterrupt, socket_errors_socket_closed, socket_errors_nonblocking, HandleInterrupt,
@ -21,7 +24,7 @@ from calibre.utils.socket_inheritance import set_socket_inherit
from calibre.utils.logging import ThreadSafeLog from calibre.utils.logging import ThreadSafeLog
from calibre.utils.monotonic import monotonic from calibre.utils.monotonic import monotonic
READ, WRITE, RDWR = 'READ', 'WRITE', 'RDWR' READ, WRITE, RDWR, WAIT = 'READ', 'WRITE', 'RDWR', 'WAIT'
WAKEUP, JOB_DONE = bytes(bytearray(xrange(2))) WAKEUP, JOB_DONE = bytes(bytearray(xrange(2)))
class ReadBuffer(object): # {{{ class ReadBuffer(object): # {{{
@ -113,8 +116,8 @@ class ReadBuffer(object): # {{{
class Connection(object): # {{{ class Connection(object): # {{{
def __init__(self, socket, opts, ssl_context, tdir, addr): def __init__(self, socket, opts, ssl_context, tdir, addr, pool):
self.opts = opts self.opts, self.pool = opts, pool
try: try:
self.remote_addr = addr[0] self.remote_addr = addr[0]
self.remote_port = addr[1] self.remote_port = addr[1]
@ -234,6 +237,21 @@ class Connection(object): # {{{
except socket.error: except socket.error:
pass pass
def queue_job(self, func, *args):
if args:
func = partial(func, *args)
try:
self.pool.put_nowait(self.socket.fileno(), func)
except Full:
raise JobQueueFull()
self.set_state(WAIT, self._job_done)
def _job_done(self, event):
self.job_done(*event)
def job_done(self, ok, result):
raise NotImplementedError()
@property @property
def state_description(self): def state_description(self):
return '' return ''
@ -241,6 +259,9 @@ class Connection(object): # {{{
def report_unhandled_exception(self, e, formatted_traceback): def report_unhandled_exception(self, e, formatted_traceback):
pass pass
def report_busy(self):
pass
def connection_ready(self): def connection_ready(self):
raise NotImplementedError() raise NotImplementedError()
@ -286,6 +307,7 @@ class ServerLoop(object):
self.bind_address = self.pre_activated_socket.getsockname() self.bind_address = self.pre_activated_socket.getsockname()
self.create_control_connection() self.create_control_connection()
self.pool = ThreadPool(self.log, self.job_completed, count=self.opts.worker_count)
def create_control_connection(self): def create_control_connection(self):
self.control_in, self.control_out = create_sock_pair() self.control_in, self.control_out = create_sock_pair()
@ -343,6 +365,7 @@ class ServerLoop(object):
self.bound_address = ba = self.socket.getsockname() self.bound_address = ba = self.socket.getsockname()
if isinstance(ba, tuple): if isinstance(ba, tuple):
ba = ':'.join(map(type(''), ba)) ba = ':'.join(map(type(''), ba))
self.pool.start()
with TemporaryDirectory(prefix='srv-') as tdir: with TemporaryDirectory(prefix='srv-') as tdir:
self.tdir = tdir self.tdir = tdir
self.ready = True self.ready = True
@ -351,13 +374,14 @@ class ServerLoop(object):
while self.ready: while self.ready:
try: try:
self.tick() self.tick()
except (KeyboardInterrupt, SystemExit) as e: except SystemExit:
self.shutdown() self.shutdown()
if isinstance(e, SystemExit): raise
raise except KeyboardInterrupt:
break break
except: except:
self.log.exception('Error in ServerLoop.tick') self.log.exception('Error in ServerLoop.tick')
self.shutdown()
def setup_socket(self): def setup_socket(self):
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@ -388,14 +412,17 @@ class ServerLoop(object):
read_needed, write_needed, readable, remove = [], [], [], [] read_needed, write_needed, readable, remove = [], [], [], []
for s, conn in self.connection_map.iteritems(): for s, conn in self.connection_map.iteritems():
if now - conn.last_activity > self.opts.timeout: if now - conn.last_activity > self.opts.timeout:
if not conn.handle_timeout(): if conn.handle_timeout():
conn.last_activity = now
else:
remove.append((s, conn)) remove.append((s, conn))
continue continue
if conn.wait_for is READ: wf = conn.wait_for
if wf is READ:
(readable if conn.read_buffer.has_data else read_needed).append(s) (readable if conn.read_buffer.has_data else read_needed).append(s)
elif conn.wait_for is WRITE: elif wf is WRITE:
write_needed.append(s) write_needed.append(s)
else: elif wf is RDWR:
write_needed.append(s) write_needed.append(s)
(readable if conn.read_buffer.has_data else read_needed).append(s) (readable if conn.read_buffer.has_data else read_needed).append(s)
@ -434,10 +461,20 @@ class ServerLoop(object):
conn.handle_event(event) conn.handle_event(event)
if not conn.ready: if not conn.ready:
self.close(s, conn) self.close(s, conn)
except JobQueueFull:
self.log.exception('Server busy handling request: ' % conn.state_description)
if conn.ready:
if conn.response_started:
self.close(s, conn)
else:
try:
conn.report_busy()
except Exception:
self.close(s, conn)
except Exception as e: except Exception as e:
ignore.add(s) ignore.add(s)
self.log.exception('Unhandled exception in state: %s' % conn.state_description)
if conn.ready: if conn.ready:
self.log.exception('Unhandled exception in state: %s' % conn.state_description)
if conn.response_started: if conn.response_started:
self.close(s, conn) self.close(s, conn)
else: else:
@ -452,6 +489,19 @@ class ServerLoop(object):
def wakeup(self): def wakeup(self):
self.control_in.sendall(WAKEUP) self.control_in.sendall(WAKEUP)
def job_completed(self):
self.control_in.sendall(JOB_DONE)
def dispatch_job_results(self):
while True:
try:
s, ok, result = self.pool.get_nowait()
except Empty:
break
conn = self.connection_map.get(s)
if conn is not None:
yield s, conn, (ok, result)
def close(self, s, conn): def close(self, s, conn):
self.connection_map.pop(s, None) self.connection_map.pop(s, None)
conn.close() conn.close()
@ -465,7 +515,7 @@ class ServerLoop(object):
if sock is not None: if sock is not None:
s = sock.fileno() s = sock.fileno()
if s > -1: if s > -1:
self.connection_map[s] = conn = self.handler(sock, self.opts, self.ssl_context, self.tdir, addr) self.connection_map[s] = conn = self.handler(sock, self.opts, self.ssl_context, self.tdir, addr, self.pool)
if self.ssl_context is not None: if self.ssl_context is not None:
yield s, conn, RDWR yield s, conn, RDWR
elif s == control: elif s == control:
@ -478,7 +528,8 @@ class ServerLoop(object):
self.create_control_connection() self.create_control_connection()
continue continue
if c == JOB_DONE: if c == JOB_DONE:
pass for s, conn, event in self.dispatch_job_results():
yield s, conn, event
elif c == WAKEUP: elif c == WAKEUP:
pass pass
elif not c: elif not c:
@ -510,6 +561,7 @@ class ServerLoop(object):
pass pass
for s, conn in tuple(self.connection_map.iteritems()): for s, conn in tuple(self.connection_map.iteritems()):
self.close(s, conn) self.close(s, conn)
self.pool.stop(self.opts.shutdown_timeout)
class EchoLine(Connection): # {{{ class EchoLine(Connection): # {{{

View File

@ -52,6 +52,10 @@ raw_options = (
'compress_min_size', 1024, 'compress_min_size', 1024,
None, None,
'Number of worker threads to use to process requests',
'worker_count', 10,
None,
'Use zero copy file transfers for increased performance', 'Use zero copy file transfers for increased performance',
'use_sendfile', True, 'use_sendfile', True,
'This will use zero-copy in-kernel transfers when sending files over the network,' 'This will use zero-copy in-kernel transfers when sending files over the network,'

83
src/calibre/srv/pool.py Normal file
View File

@ -0,0 +1,83 @@
#!/usr/bin/env python2
# vim:fileencoding=utf-8
from __future__ import (unicode_literals, division, absolute_import,
print_function)
__license__ = 'GPL v3'
__copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
import sys, time
from Queue import Queue, Full
from threading import Thread
class Worker(Thread):
daemon = True
def __init__(self, log, notify_server, num, request_queue, result_queue):
self.request_queue, self.result_queue = request_queue, result_queue
self.notify_server = notify_server
self.log = log
self.working = False
Thread.__init__(self, name='ServerWorker%d' % num)
def run(self):
while True:
x = self.request_queue.get()
if x is None:
break
job_id, func = x
self.working = True
try:
result = func()
except Exception:
self.handle_error(job_id) # must be a separate function to avoid reference cycles with sys.exc_info()
else:
self.result_queue.put((job_id, True, result))
finally:
self.working = False
try:
self.notify_server()
except Exception:
self.log.exception('ServerWorker failed to notify server on job completion')
def handle_error(self, job_id):
self.result_queue.put((job_id, False, sys.exc_info()))
class ThreadPool(object):
def __init__(self, log, notify_server, count=10, queue_size=1000):
self.request_queue, self.result_queue = Queue(queue_size), Queue(queue_size)
self.workers = [Worker(log, notify_server, i, self.request_queue, self.result_queue) for i in xrange(count)]
def start(self):
for w in self.workers:
w.start()
def put_nowait(self, job_id, func):
self.request_queue.put_nowait((job_id, func))
def get_nowait(self):
return self.result_queue.get_nowait()
def stop(self, shutdown_timeout):
end = time.time() + shutdown_timeout
for w in self.workers:
try:
self.request_queue.put_nowait(None)
except Full:
break
for w in self.workers:
now = time.time()
if now >= end:
break
w.join(end - now)
self.workers = [w for w in self.workers if w.is_alive()]
@property
def busy(self):
return sum(int(w.working) for w in self.workers)
@property
def idle(self):
return sum(int(not w.working) for w in self.workers)

View File

@ -21,6 +21,14 @@ from calibre.ptempfile import TemporaryDirectory
class LoopTest(BaseTest): class LoopTest(BaseTest):
def test_workers(self):
' Test worker semantics '
with TestServer(lambda data:(data.path[0] + data.read()), worker_count=3) as server:
self.ae(3, sum(int(w.is_alive()) for w in server.loop.pool.workers))
server.loop.stop()
server.join()
self.ae(0, sum(int(w.is_alive()) for w in server.loop.pool.workers))
@skipIf(create_server_cert is None, 'certgen module not available') @skipIf(create_server_cert is None, 'certgen module not available')
def test_ssl(self): def test_ssl(self):
'Test serving over SSL' 'Test serving over SSL'