ADR-005: ThreadPoolExecutor for concurrency, not asyncio¶
- Status: accepted
Context¶
Python offers three mainstream concurrency models for a network server:
- Thread-per-connection / ThreadPoolExecutor — blocking I/O, stdlib
socket,ssl,threading. - asyncio — non-blocking I/O,
asyncio.StreamReader/Writer,asyncio.start_server, oruvloop. - Multiprocessing — separate processes per request (overkill for a file server).
The server's workload is dominated by file I/O (uploads, downloads, CAPTCHA rendering) and occasional crypto (ECDH, AES-GCM). Target scale is up to ~10 concurrent clients — small teams, research labs, CTFs.
Decision¶
Use ThreadPoolExecutor(max_workers=self.max_workers) with blocking sockets.
- Default pool size is 10 (tunable via
--max-workers). - TLS handshake happens in the worker thread, so a slow handshake does not block the accept loop.
- Keep-alive is per-worker — a thread processes all requests on its connection until idle timeout or MAX_REQUESTS.
Consequences¶
Positive¶
- Handlers are written in straight-line, blocking style — much easier to read than async coroutines, which matters for a learning-oriented project.
ssl.SSLContext.wrap_socketworks natively; the async equivalent (asyncio.start_server(ssl=context)) has historical footguns.- File I/O uses regular
open(),read(),write(); no need foraiofiles/ thread-offload tricks. - Debugging is straightforward: threads show up in
py-spy,gdbwith Python extensions, and plainthreading.enumerate().
Negative¶
- Limited to ~hundreds of concurrent connections before the GIL and context-switch overhead dominate. Not a design target — a production file server at that scale should be behind nginx/Caddy.
- Graceful shutdown is implemented with polling (
settimeout(1.0)on accept) rather than event-driven; the 1-second shutdown latency is a known trade-off. - Some primitives (e.g. the WebSocket notepad) poll their socket — an async rewrite could be more efficient, but the complexity cost is not justified at current scale.
If we ever need to revisit¶
Revisit this ADR when:
- Benchmarks show ThreadPool becomes the bottleneck (GIL contention, not I/O).
- A major new feature requires long-lived thousands of concurrent WebSocket connections.
- Python's default concurrency model changes (PEP 703 / no-GIL).