Skip to content

Commit 0dcf6a1

Browse files
authored
Merge pull request #516 from pR0Ps/bugfix/workerthread-name
Before this patch, the `ThreadPool.start` and `ThreadPool.grow` functions both added threads to the pool, but both implemented their own versions that were similar, but not exactly the same. This change refactors the `ThreadPool.start()` function to use `ThreadPool.grow()` to create the initial workers, reducing code duplication. It also adds some error checking that was not previously there, and unit tests to exercise them.
2 parents 7aa5759 + 2f16a25 commit 0dcf6a1

File tree

3 files changed

+123
-22
lines changed

3 files changed

+123
-22
lines changed

.flake8

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ per-file-ignores =
109109
cheroot/test/test_core.py: C815, DAR101, DAR201, DAR401, I003, I004, N805, N806, S101, WPS110, WPS111, WPS114, WPS121, WPS202, WPS204, WPS226, WPS229, WPS302, WPS306, WPS317, WPS323, WPS324, WPS326, WPS421, WPS422, WPS432, WPS437, WPS442
110110
cheroot/test/test_dispatch.py: DAR101, DAR201, S101, WPS111, WPS121, WPS302, WPS422, WPS430
111111
cheroot/test/test_ssl.py: C818, DAR101, DAR201, DAR301, DAR401, E800, I001, I003, I004, I005, S101, S309, S404, S603, WPS100, WPS110, WPS111, WPS114, WPS121, WPS130, WPS201, WPS202, WPS204, WPS210, WPS211, WPS218, WPS219, WPS222, WPS226, WPS231, WPS300, WPS301, WPS317, WPS318, WPS324, WPS326, WPS335, WPS336, WPS337, WPS352, WPS408, WPS420, WPS421, WPS422, WPS432, WPS436, WPS440, WPS441, WPS442, WPS450, WPS509, WPS510, WPS608
112-
cheroot/test/test_server.py: DAR101, DAR201, DAR301, I001, I003, I004, I005, S101, WPS110, WPS111, WPS118, WPS121, WPS122, WPS130, WPS201, WPS202, WPS210, WPS218, WPS229, WPS300, WPS317, WPS318, WPS324, WPS326, WPS421, WPS422, WPS430, WPS432, WPS433, WPS436, WPS437, WPS442, WPS507, WPS509, WPS608
112+
cheroot/test/test_server.py: DAR101, DAR201, DAR301, I001, I003, I004, I005, S101, WPS110, WPS111, WPS118, WPS121, WPS122, WPS130, WPS201, WPS202, WPS210, WPS218, WPS226, WPS229, WPS300, WPS317, WPS318, WPS324, WPS326, WPS421, WPS422, WPS430, WPS432, WPS433, WPS436, WPS437, WPS442, WPS507, WPS509, WPS608
113113
cheroot/test/test_conn.py: B007, DAR101, DAR201, DAR301, DAR401, E800, I001, I003, I004, I005, N802, N805, RST304, S101, S310, WPS100, WPS110, WPS111, WPS114, WPS115, WPS120, WPS121, WPS122, WPS201, WPS202, WPS204, WPS210, WPS211, WPS213, WPS214, WPS218, WPS219, WPS226, WPS231, WPS301, WPS306, WPS317, WPS318, WPS323, WPS326, WPS361, WPS420, WPS421, WPS422, WPS425, WPS429, WPS430, WPS432, WPS435, WPS436, WPS437, WPS440, WPS442, WPS447, WPS462, WPS508, WPS509, WPS510, WPS526
114114
cheroot/test/webtest.py: B007, DAR101, DAR201, DAR401, I001, I003, I004, N802, RST303, RST304, S101, S104, WPS100, WPS110, WPS111, WPS115, WPS120, WPS121, WPS122, WPS201, WPS202, WPS204, WPS210, WPS211, WPS213, WPS214, WPS220, WPS221, WPS223, WPS229, WPS230, WPS231, WPS236, WPS301, WPS306, WPS317, WPS323, WPS326, WPS338, WPS361, WPS414, WPS420, WPS421, WPS422, WPS430, WPS432, WPS433, WPS437, WPS440, WPS501, WPS503, WPS505, WPS601
115115
cheroot/testing.py: B014, C815, DAR101, DAR201, DAR301, I001, I003, S104, WPS100, WPS211, WPS229, WPS301, WPS306, WPS317, WPS414, WPS420, WPS422, WPS430, WPS503, WPS526

cheroot/test/test_server.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import socket
66
import tempfile
77
import threading
8+
import types
89
import uuid
910
import urllib.parse # noqa: WPS301
1011

@@ -17,6 +18,7 @@
1718
from .._compat import bton, ntob
1819
from .._compat import IS_LINUX, IS_MACOS, IS_WINDOWS, SYS_PLATFORM
1920
from ..server import IS_UID_GID_RESOLVABLE, Gateway, HTTPServer
21+
from ..workers.threadpool import ThreadPool
2022
from ..testing import (
2123
ANY_INTERFACE_IPV4,
2224
ANY_INTERFACE_IPV6,
@@ -440,3 +442,90 @@ def many_open_sockets(request, resource_limit):
440442
# Close our open resources
441443
for test_socket in test_sockets:
442444
test_socket.close()
445+
446+
447+
@pytest.mark.parametrize(
448+
('minthreads', 'maxthreads', 'inited_maxthreads'),
449+
(
450+
(
451+
# NOTE: The docstring only mentions -1 to mean "no max", but other
452+
# NOTE: negative numbers should also work.
453+
1,
454+
-2,
455+
float('inf'),
456+
),
457+
(1, -1, float('inf')),
458+
(1, 1, 1),
459+
(1, 2, 2),
460+
(1, float('inf'), float('inf')),
461+
(2, -2, float('inf')),
462+
(2, -1, float('inf')),
463+
(2, 2, 2),
464+
(2, float('inf'), float('inf')),
465+
),
466+
)
467+
def test_threadpool_threadrange_set(minthreads, maxthreads, inited_maxthreads):
468+
"""Test setting the number of threads in a ThreadPool.
469+
470+
The ThreadPool should properly set the min+max number of the threads to use
471+
in the pool if those limits are valid.
472+
"""
473+
tp = ThreadPool(
474+
server=None,
475+
min=minthreads,
476+
max=maxthreads,
477+
)
478+
assert tp.min == minthreads
479+
assert tp.max == inited_maxthreads
480+
481+
482+
@pytest.mark.parametrize(
483+
('minthreads', 'maxthreads', 'error'),
484+
(
485+
(-1, -1, 'min=-1 must be > 0'),
486+
(-1, 0, 'min=-1 must be > 0'),
487+
(-1, 1, 'min=-1 must be > 0'),
488+
(-1, 2, 'min=-1 must be > 0'),
489+
(0, -1, 'min=0 must be > 0'),
490+
(0, 0, 'min=0 must be > 0'),
491+
(0, 1, 'min=0 must be > 0'),
492+
(0, 2, 'min=0 must be > 0'),
493+
(1, 0, 'Expected an integer or the infinity value for the `max` argument but got 0.'),
494+
(1, 0.5, 'Expected an integer or the infinity value for the `max` argument but got 0.5.'),
495+
(2, 0, 'Expected an integer or the infinity value for the `max` argument but got 0.'),
496+
(2, '1', "Expected an integer or the infinity value for the `max` argument but got '1'."),
497+
(2, 1, 'max=1 must be > min=2'),
498+
),
499+
)
500+
def test_threadpool_invalid_threadrange(minthreads, maxthreads, error):
501+
"""Test that a ThreadPool rejects invalid min/max values.
502+
503+
The ThreadPool should raise an error with the proper message when
504+
initialized with an invalid min+max number of threads.
505+
"""
506+
with pytest.raises((ValueError, TypeError), match=error):
507+
ThreadPool(
508+
server=None,
509+
min=minthreads,
510+
max=maxthreads,
511+
)
512+
513+
514+
def test_threadpool_multistart_validation(monkeypatch):
515+
"""Test for ThreadPool multi-start behavior.
516+
517+
Tests that when calling start() on a ThreadPool multiple times raises a
518+
:exc:`RuntimeError`
519+
"""
520+
# replace _spawn_worker with a function that returns a placeholder to avoid
521+
# actually starting any threads
522+
monkeypatch.setattr(
523+
ThreadPool,
524+
'_spawn_worker',
525+
lambda _: types.SimpleNamespace(ready=True),
526+
)
527+
528+
tp = ThreadPool(server=None)
529+
tp.start()
530+
with pytest.raises(RuntimeError, match='Threadpools can only be started once.'):
531+
tp.start()

cheroot/workers/threadpool.py

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -151,12 +151,33 @@ def __init__(
151151
server (cheroot.server.HTTPServer): web server object
152152
receiving this request
153153
min (int): minimum number of worker threads
154-
max (int): maximum number of worker threads
154+
max (int): maximum number of worker threads (-1/inf for no max)
155155
accepted_queue_size (int): maximum number of active
156156
requests in queue
157157
accepted_queue_timeout (int): timeout for putting request
158158
into queue
159+
160+
:raises ValueError: if the min/max values are invalid
161+
:raises TypeError: if the max is not an integer or inf
159162
"""
163+
if min < 1:
164+
raise ValueError(f'min={min!s} must be > 0')
165+
166+
if max == float('inf'):
167+
pass
168+
elif not isinstance(max, int) or max == 0:
169+
raise TypeError(
170+
'Expected an integer or the infinity value for the `max` '
171+
f'argument but got {max!r}.',
172+
)
173+
elif max < 0:
174+
max = float('inf')
175+
176+
if max < min:
177+
raise ValueError(
178+
f'max={max!s} must be > min={min!s} (or infinity for no max)',
179+
)
180+
160181
self.server = server
161182
self.min = min
162183
self.max = max
@@ -167,18 +188,13 @@ def __init__(
167188
self._pending_shutdowns = collections.deque()
168189

169190
def start(self):
170-
"""Start the pool of threads."""
171-
for _ in range(self.min):
172-
self._threads.append(WorkerThread(self.server))
173-
for worker in self._threads:
174-
worker.name = (
175-
'CP Server {worker_name!s}'.
176-
format(worker_name=worker.name)
177-
)
178-
worker.start()
179-
for worker in self._threads:
180-
while not worker.ready:
181-
time.sleep(.1)
191+
"""Start the pool of threads.
192+
193+
:raises RuntimeError: if the pool is already started
194+
"""
195+
if self._threads:
196+
raise RuntimeError('Threadpools can only be started once.')
197+
self.grow(self.min)
182198

183199
@property
184200
def idle(self): # noqa: D401; irrelevant for properties
@@ -206,17 +222,13 @@ def _clear_dead_threads(self):
206222

207223
def grow(self, amount):
208224
"""Spawn new worker threads (not above self.max)."""
209-
if self.max > 0:
210-
budget = max(self.max - len(self._threads), 0)
211-
else:
212-
# self.max <= 0 indicates no maximum
213-
budget = float('inf')
214-
225+
budget = max(self.max - len(self._threads), 0)
215226
n_new = min(amount, budget)
216227

217228
workers = [self._spawn_worker() for i in range(n_new)]
218-
while not all(worker.ready for worker in workers):
219-
time.sleep(.1)
229+
for worker in workers:
230+
while not worker.ready:
231+
time.sleep(.1)
220232
self._threads.extend(workers)
221233

222234
def _spawn_worker(self):

0 commit comments

Comments
 (0)