ch20.concurrent_executors
Ch 20. Concurrent executors
- Michele Simionato's View: For 99% of application programming use cases, the simple pattern of spawning independent threads and collecting results in a queue is sufficient.
concurrent.futures.ExecutorClasses:- Encapsulates the pattern described by Simionato: spawning independent threads and gathering results via queues.
- Makes it easy to apply this pattern, not only with threads but also with processes, which is ideal for CPU-bound tasks.
Concurrent Web Downloads
The goal is to compare sequential and concurrent approaches to see how concurrency boosts efficiency in network I/O.
Scripts
-
flags.py(Sequential):- Downloads images one after the other, waiting for each to complete before requesting the next.
-
flags_threadpool.py(Concurrent with Threads):- Uses
concurrent.futures.ThreadPoolExecutorto download multiple images concurrently.
- Uses
-
flags_asyncio.py(Concurrent with Asyncio):- Uses
asyncioto handle concurrent downloads without threads, relying on asynchronous coroutines.
- Uses
Performance Results
- Sequential (
flags.py): Average download time: 7.18 seconds. - Threadpool (
flags_threadpool.py): Average download time: 1.40 seconds. - Asyncio (
flags_asyncio.py): Average download time: 1.35 seconds.
Key Observations
- Order of Downloads:
- The order of downloaded flags is different in each run of the concurrent scripts (
flags_threadpool.pyandflags_asyncio.py) due to the parallel nature of the downloads.
- The order of downloaded flags is different in each run of the concurrent scripts (
- Speed Improvement:
- Both concurrent scripts are more than 5 times faster than the sequential script.
- The concurrent approach significantly improves throughput in network I/O operations, even with just 20 small files.
- Concurrency Approach:
- Both thread-based (
flags_threadpool.py) and coroutine-based (flags_asyncio.py) implementations provide similar performance for this task. - For HTTP clients managing a controlled number of requests, the performance difference between threads and coroutines is minimal.
- Both thread-based (
Sequential Script (flags.py)
def download_many(cc_list: list[str]) -> int:
for cc in sorted(cc_list):
image = get_flag(cc)
save_flag(image, f'{cc}.gif')
print(cc, end=' ', flush=True)
return len(cc_list)
if __name__ == '__main__':
main(download_many)
-
Country Codes:
- A list of ISO 3166 country codes for the 20 most populous countries.
- Example:
POP20_CC = ('CN IN US ID BR PK NG BD RU JP MX PH VN ET EG DE IR TR CD FR').split()
-
Functions:
save_flag(img, filename): Saves the flag image locally.get_flag(cc): Downloads a flag image given the country code by sending an HTTP request usinghttpx.download_many(cc_list):- Iterates through the country codes sequentially, downloads each flag, and saves it.
- Displays the country codes as they are processed.
-
Main Execution:
- The
mainfunction records the start time, callsdownload_manyto download all flags, and prints the total time taken.
- The
-
Performance:
- Synchronous and processes one flag at a time, leading to a total download time of around 7 seconds for 20 flags.
Threaded Concurrent Script (flags_threadpool.py)
from concurrent import futures
from flags import save_flag, get_flag, main
def download_one(cc: str):
image = get_flag(cc)
save_flag(image, f'{cc}.gif')
print(cc, end=' ', flush=True)
return cc
def download_many(cc_list: list[str]) -> int:
with futures.ThreadPoolExecutor() as executor:
res = executor.map(download_one, sorted(cc_list))
return len(list(res))
if __name__ == '__main__':
main(download_many)
-
Reuse of Functions:
save_flagandget_flagare reused from the sequential script.
-
Functions:
download_one(cc): Downloads and saves a single flag image (similar to the body of theforloop in the sequential version).download_many(cc_list):- Uses
ThreadPoolExecutorto executedownload_oneconcurrently across multiple threads. executor.map(download_one, sorted(cc_list))runs the download tasks concurrently for the sorted list of country codes.
- Uses
-
Concurrency Management:
ThreadPoolExecutor:- Manages the creation of worker threads for concurrent downloads.
- By default, uses a sensible number of worker threads based on the number of CPU cores.
-
Performance:
- Concurrent: The script downloads multiple flags simultaneously, reducing total download time to around 1.4 seconds.
-
Key Takeaways
-
Concurrency Simplified:
concurrent.futures.ThreadPoolExecutormakes adding concurrency on top of sequential code easy, without significant changes to the original code.
-
ThreadPoolExecutor:
- Manages worker threads efficiently, with a default maximum number of workers that balances performance and resource use based on the CPU count.
-
Performance Improvement:
- Concurrency can reduce the total time significantly, especially in network I/O-bound tasks like downloading files.
-
Where Are the Futures?
Futures Overview
- Purpose: A
Futurerepresents a deferred computation that may or may not have completed. - Classes:
concurrent.futures.Future(used for threads/processes)asyncio.Future(used in asynchronous programming)
- Role: Futures encapsulate pending operations, allow checking their status, and retrieve results (or exceptions) once available.
Key Properties of Futures
-
done()Method:- Returns a Boolean indicating if the computation has finished (non-blocking).
-
add_done_callback():- Attaches a callback that gets called when the future is complete.
-
result()Method:- For
concurrent.futures.Future: Blocks until the result is ready, or raisesTimeoutErrorif a timeout is provided and exceeded. - For
asyncio.Future: No timeout support; you typically useawaitto get the result.
- For
Futures in Action
-
executor.submit():- Schedules a callable to run and returns a
Futureobject representing the pending result. - The future’s state changes once the computation finishes, and you can retrieve the result.
- Schedules a callable to run and returns a
-
futures.as_completed():- Takes an iterable of futures and returns an iterator that yields each future as it completes.
Use futures explicitly
-
Submit Futures:
- For each country code, we submit the download task using
executor.submit(), which returns a future. - Futures are stored in a list (
to_do) for later retrieval.
- For each country code, we submit the download task using
-
Process Completed Futures:
- We loop through the futures as they complete using
futures.as_completed(to_do). - For each completed future, we call
future.result()to get the result.
- We loop through the futures as they complete using
Code Example
def download_many(cc_list: list[str]) -> int:
cc_list = cc_list[:5] # Limit to the first 5 countries for demonstration.
with futures.ThreadPoolExecutor(max_workers=3) as executor:
to_do: list[futures.Future] = []
for cc in sorted(cc_list):
future = executor.submit(download_one, cc)
to_do.append(future)
print(f'Scheduled for {cc}: {future}')
# 1 at the end is start index of enumerate
for count, future in enumerate(futures.as_completed(to_do), 1):
res: str = future.result()
print(f'{future} result: {res!r}')
return count
Output Example
$ python3 flags_threadpool_futures.py
Scheduled for BR: <Future at 0x100791518 state=running>
Scheduled for CN: <Future at 0x100791710 state=running>
Scheduled for ID: <Future at 0x100791a90 state=running>
Scheduled for IN: <Future at 0x101807080 state=pending>
Scheduled for US: <Future at 0x101807128 state=pending>
CN <Future at 0x100791710 state=finished returned str> result: 'CN'
BR ID <Future at 0x100791518 state=finished returned str> result: 'BR'
<Future at 0x100791a90 state=finished returned str> result: 'ID'
IN <Future at 0x101807080 state=finished returned str> result: 'IN'
US <Future at 0x101807128 state=finished returned str> result: 'US'
5 downloads in 0.70s
Key Points from Example
- Futures State: Futures have various states like
running,pending, orfinished. This state is shown in the output, allowing us to track each task's progress. - Order of Completion: Futures are scheduled in alphabetical order, but since they complete at different times, the results appear in a different order based on when each future finishes.
Why Use futures.as_completed?
- It provides more control over individual futures compared to
executor.map, which directly returns results in submission order. - You can inspect and react to each future as it completes.
Takeaway
- Futures are an integral part of handling concurrency in both
concurrent.futuresandasyncio, providing a way to manage and track asynchronous operations. executor.mapis simpler but hides futures, whilefutures.as_completedgives more transparency and control, showing futures in action.
Launching Processes with concurrent.futures
- Purpose: The
ProcessPoolExecutorclass inconcurrent.futuresallows easy parallel execution of CPU-bound tasks across multiple processes. - Benefit: Hides the complexity of managing processes, inter-process communication, and task distribution, making the code cleaner and easier to maintain.
- Use Case: Best suited for CPU-bound tasks, unlike the thread-based solution, which is ideal for I/O-bound tasks.
Multicore Prime Checker Redux
from concurrent import futures
from time import perf_counter
from primes import is_prime, NUMBERS
from typing import NamedTuple
class PrimeResult(NamedTuple):
n: int
flag: bool
elapsed: float
def check(n: int) -> PrimeResult:
t0 = perf_counter()
res = is_prime(n)
return PrimeResult(n, res, perf_counter() - t0)
def main() -> None:
workers = int(sys.argv[1])
with futures.ProcessPoolExecutor(workers) as executor:
print(f'Checking {len(NUMBERS)} numbers with {executor._max_workers} processes:')
t0 = perf_counter()
numbers = sorted(NUMBERS, reverse=True) # Sort numbers in descending order
for n, prime, elapsed in executor.map(check, numbers):
label = 'P' if prime else ' '
print(f'{n:16} {label} {elapsed:9.6f}s')
print(f'Total time: {perf_counter() - t0:.2f}s')
if __name__ == '__main__':
main()
-
Simplified Imports:
- No need to manually handle
multiprocessing, queues, or workers. All process management is handled byProcessPoolExecutor.
- No need to manually handle
-
Prime Checking Function:
check(n): Determines if a number is prime and returns a tuple containing the number, whether it's prime, and the elapsed time.
-
Process Management:
- Uses
ProcessPoolExecutorwith an optionalmax_workersparameter to decide how many processes to launch (defaults to the number of CPU cores). - The number of workers is determined dynamically by
os.cpu_count()ifmax_workersis not provided.
- Uses
-
Execution Order:
- Numbers are submitted to the process pool in descending order, but results are returned in the same order they were submitted, which is a key difference compared to custom multiprocessing.
$ ./proc_pool.py
Checking 20 numbers with 12 processes:
9999999999999999 0.000024s
9999999999999917 P 9.500677s
7777777777777777 0.000022s
7777777777777753 P 8.976933s
...
Total time: 9.65s
- Quick Results: The first number (
9999999999999999) returns almost instantly because it’s composite with a small divisor. Similarly, other composite numbers return quickly. - Long Delays: The second number (
9999999999999917), the largest prime, takes over 9 seconds, significantly slowing down its result. - Order of Results: Although some numbers take longer to check, results are returned in descending order, not based on computation time.
Key Differences from procs.py
- Task Submission Order: The numbers are submitted to the worker processes in descending order, so the results appear in that order, regardless of how long each number takes to process.
- Execution Visibility: The output does not reflect how fast each task completes (e.g., quick composites and slow primes). Instead, the results are returned in submission order, making progress less visible compared to
procs.py.
Summary
- Simplified Process Management: Using
ProcessPoolExecutorsimplifies the code significantly - Efficiency for CPU-Bound Tasks: It’s ideal for parallelizing CPU-heavy tasks, utilizing all available CPU cores.
- Ordered Results:
executor.map()returns results in the same order as tasks are submitted, which may hide how long individual tasks take but ensures predictable output. - Practical Use: For CPU-bound tasks like primality testing,
ProcessPoolExecutoroffers clean, efficient, and scalable parallel execution.
Experimenting with Executor.map
from time import sleep, strftime
from concurrent import futures
def display(*args):
print(strftime('[%H:%M:%S]'), end=' ')
print(*args)
def loiter(n):
msg = '{}loiter({}): doing nothing for {}s...'
display(msg.format('\t'*n, n, n))
sleep(n)
msg = '{}loiter({}): done.'
display(msg.format('\t'*n, n))
return n * 10
def main():
display('Script starting.')
executor = futures.ThreadPoolExecutor(max_workers=3)
results = executor.map(loiter, range(5))
display('results:', results)
display('Waiting for individual results:')
for i, result in enumerate(results):
display(f'result {i}: {result}')
if __name__ == '__main__':
main()
-
display(*args):- A helper function that prints messages with a timestamp for easy tracking of when events occur.
-
loiter(n):- A function that simulates a task doing nothing for
nseconds (sleep(n)), and then returnsn * 10as the result. - The function prints a message before and after sleeping, indented by
ntabs for clarity.
- A function that simulates a task doing nothing for
-
main():- Creates a
ThreadPoolExecutorwith 3 threads (workers). - Uses
executor.map(loiter, range(5))to submit 5 tasks (each callingloiterwith values from 0 to 4). - The results of
executor.mapare retrieved in order, even though the tasks may complete at different times.
- Creates a
Execution Flow
- The
executor.mapfunction submits all tasks at once, but only three tasks start immediately because there are only 3 worker threads. Once a thread finishes a task, the next pending task begins. - Non-blocking submission: The
mapcall is non-blocking; tasks are submitted to the thread pool, and the program continues running. - Blocking result retrieval: When iterating over
results, the loop waits for each result to be ready before moving to the next.
[08:11:50] Script starting.
[08:11:50] loiter(0): doing nothing for 0s...
[08:11:50] loiter(0): done.
[08:11:50] loiter(1): doing nothing for 1s...
[08:11:50] loiter(3): doing nothing for 3s...
[08:11:50] results: <generator object Executor.map.<locals>.result_iterator at 0x7fb48f567ed0>
[08:11:50] Waiting for individual results:
[08:11:50] result 0: 0
[08:11:50] loiter(2): doing nothing for 2s...
[08:11:51] loiter(1): done.
[08:11:51] loiter(4): doing nothing for 4s...
[08:11:51] result 1: 10
[08:11:52] loiter(2): done.
[08:11:52] result 2: 20
[08:11:53] loiter(3): done.
[08:11:53] result 3: 30
[08:11:55] loiter(4): done.
[08:11:55] result 4: 40
- Task scheduling:
- The first three tasks (
loiter(0),loiter(1),loiter(2)) begin immediately since there are 3 worker threads. loiter(0)finishes instantly, as it sleeps for 0 seconds.- The
resultsobject is a generator, and the program prints that fact, confirming no blocking has occurred yet.
- The first three tasks (
- Results collection:
loiter(1)finishes after 1 second, making the thread available forloiter(3).- Similarly,
loiter(2)finishes after 2 seconds, allowing the final task,loiter(4), to begin. - The results are printed in order of submission, even though tasks finish out of order.
Important Takeaways
-
executor.mapreturns a generator:- The
mapmethod submits all tasks immediately, but the results are yielded in the order the tasks were submitted, not when they complete. - The results are not returned until the corresponding task finishes, causing each iteration in the
forloop to potentially block while waiting for the result.
- The
-
Non-blocking task submission, blocking result retrieval:
- Task submission to the
ThreadPoolExecutordoes not block execution, but retrieving results from the generator blocks until each result is ready.
- Task submission to the
-
Worker reuse:
- Once a worker finishes its task, it immediately picks up the next available task, maximizing concurrency with the given number of workers.
Flexibility with executor.submit and futures.as_completed
- While
executor.mapis easy to use, it executes the same callable on different arguments and returns results in the submission order. - Using
executor.submitandfutures.as_completedoffers more flexibility:- You can submit different callables with different arguments.
- Results are returned as they complete, allowing for more efficient processing in some cases.
Error Handling
- Scripts: The flags2 examples introduce improved error handling and a progress bar display using the
tqdmpackage. There are three versions:flags2_sequential.py: Sequential HTTP client with error handling and progress bar.flags2_threadpool.py: Concurrent HTTP client usingThreadPoolExecutor, with progress and error handling.flags2_asyncio.py: Async HTTP client usingasyncioandhttpx, with similar functionality.
Common Features
-
Progress Bar:
- The
tqdmpackage is used to display an animated progress bar for download progress. - It integrates with the
as_completedfunction (eitherfutures.as_completedorasyncio.as_completed) to update progress as tasks complete.
- The
-
Error Handling:
- Proper error handling ensures the scripts can handle HTTP errors, connection issues, and timeouts.
- Different server configurations allow testing with delays and intentional errors.
-
Command-Line Interface:
- The scripts accept various options to customize the downloads, including:
- Country codes to download specific flags.
- Concurrent request limits (e.g., max concurrent requests).
- Server selection to choose between different test servers (local, delay, error, etc.).
- Example options (shown in Example 20-10):
-a, --all: Download all available flags.-l, --limit N: Limit the number of flags to download.-m, --max_req CONCURRENT: Maximum concurrent requests (default is 30).-s, --server LABEL: Select a server (e.g.,LOCAL,REMOTE,DELAY,ERROR).
- The scripts accept various options to customize the downloads, including:
Test Servers
- LOCAL: A local HTTP server for testing, running on port 8000.
- DELAY: A server on port 8001 that introduces random delays before responding (using
slow_server.py). - ERROR: A server on port 8002 that introduces random errors (e.g., "418 I'm a teapot" error) along with delays.
Progress and Error Handling in Action
-
Progress Bar:
- As each flag is downloaded, the progress bar updates, giving an estimate of the time remaining.
- The progress bar is crucial for understanding the behavior of concurrent downloads and seeing how different server conditions (e.g., delays, errors) affect the performance.
-
Error Handling:
- The scripts gracefully handle failed downloads, logging errors, and skipping problematic requests.
- The output distinguishes between successfully downloaded flags and those that failed due to errors.
Example Runs
-
Sequential Download (Example 20-11):
-
Default settings, using 1 concurrent connection from the LOCAL server, downloads 20 flags:
$ python3 flags2_sequential.py LOCAL site: http://localhost:8000/flags Searching for 20 flags: from BD to VN 1 concurrent connection will be used. -------------------- 20 flags downloaded. Elapsed time: 0.10s
-
-
Concurrent Download with Delays (Example 20-12):
-
Download all flags starting with A, B, or C from the DELAY server (port 8001), using 30 concurrent connections:
$ python3 flags2_threadpool.py -s DELAY a b c DELAY site: http://localhost:8001/flags Searching for 78 flags: from AA to CZ 30 concurrent connections will be used. -------------------- 43 flags downloaded. 35 not found. Elapsed time: 1.72s
-
-
Async Download with Error Handling (Example 20-13):
-
Download 100 flags from the ERROR server (port 8002), using 100 concurrent requests, with 27 errors encountered:
$ python3 flags2_asyncio.py -s ERROR -al 100 -m 100 ERROR site: http://localhost:8002/flags Searching for 100 flags: from AD to LK 100 concurrent connections will be used. -------------------- 73 flags downloaded. 27 errors. Elapsed time: 0.64s
-
Key Takeaways
- Progress Tracking: The integration of the
tqdmprogress bar makes the progress of concurrent downloads visible, allowing real-time feedback on the status of downloads. - Error Handling: Handling errors such as timeouts and HTTP failures ensures robustness and reliability when downloading from various servers.
- Concurrency Impact: The use of
ThreadPoolExecutororasynciowith proper concurrency limits (e.g.,-m 100) significantly improves performance compared to the sequential approach.
Sequential Download flags2_sequential.py
- Purpose: Handles downloading multiple flags sequentially and tracks their statuses using a
Counter. - Progress Bar:
- Uses the
tqdmlibrary to display a progress bar unless in verbose mode.
- Uses the
- Error Handling:
- Catches exceptions like
HTTPStatusError(for HTTP status code errors),RequestError(for other network-related errors), andKeyboardInterrupt(for user interrupts). - For any exception, the error message is stored and displayed if verbose mode is enabled.
- Catches exceptions like
- Flow:
- Iterates over the country code list.
- Downloads each flag sequentially, updating the progress bar or printing verbose output.
- Records the status of each download (e.g.,
OK,NOT_FOUND,ERROR). - Returns a
Countersummarizing the results of the downloads.
def get_flag(base_url: str, cc: str) -> bytes:
url = f'{base_url}/{cc}/{cc}.gif'.lower()
resp = httpx.get(url, timeout=3.1, follow_redirects=True)
resp.raise_for_status() # <3>
return resp.content
def download_one(cc: str, base_url: str, verbose: bool = False) -> DownloadStatus:
try:
image = get_flag(base_url, cc)
except httpx.HTTPStatusError as exc: # <4>
res = exc.response
if res.status_code == HTTPStatus.NOT_FOUND:
status = DownloadStatus.NOT_FOUND # <5>
msg = f'not found: {res.url}'
else:
raise # <6>
else:
save_flag(image, f'{cc}.gif')
status = DownloadStatus.OK
msg = 'OK'
if verbose: # <7>
print(cc, msg)
return status
def download_many(cc_list: list[str],
base_url: str,
verbose: bool,
_unused_concur_req: int) -> Counter[DownloadStatus]:
counter: Counter[DownloadStatus] = Counter() # <1>
cc_iter = sorted(cc_list) # <2>
if not verbose:
cc_iter = tqdm.tqdm(cc_iter) # <3>
for cc in cc_iter:
try:
status = download_one(cc, base_url, verbose) # <4>
except httpx.HTTPStatusError as exc: # <5>
error_msg = 'HTTP error {resp.status_code} - {resp.reason_phrase}'
error_msg = error_msg.format(resp=exc.response)
except httpx.RequestError as exc: # <6>
error_msg = f'{exc} {type(exc)}'.strip()
except KeyboardInterrupt: # <7>
break
else: # <8>
error_msg = ''
if error_msg:
status = DownloadStatus.ERROR # <9>
counter[status] += 1 # <10>
if verbose and error_msg: # <11>
print(f'{cc} error: {error_msg}')
return counter # <12>
-
get_flag(base_url, cc):- Downloads a flag image from a URL constructed using the country code (
cc). - Uses the
httpxlibrary to perform the HTTP request with a timeout and redirect handling. - Raises an
HTTPStatusErrorif the response status code is not in the200range.
- Downloads a flag image from a URL constructed using the country code (
-
download_one(cc, base_url, verbose=False):- Calls
get_flagto download the flag for a given country code. - Catches
HTTPStatusErrorto specifically handle 404 (Not Found) errors, marking the download status asNOT_FOUND. - If successful, saves the flag image and returns a status of
OK. - Optionally prints verbose output showing the status of each download.
- Calls
Threaded Download flags2_threadpool.py
- Purpose: Downloads multiple flags concurrently, tracking their statuses and errors.
- Progress Bar:
- Uses
tqdmto update the progress bar as eachFutureis completed.
- Uses
- Error Handling:
- Handles HTTP and network-related errors similarly to the sequential version, but maps each
Futureto its corresponding country code using ato_do_map.
- Handles HTTP and network-related errors similarly to the sequential version, but maps each
- Flow:
- Creates a
ThreadPoolExecutorwith a limit on concurrent requests. - Submits download tasks (
download_one) to the executor and tracks eachFuture. - Uses
as_completedto retrieve results as tasks complete, allowing the progress bar to reflect real-time updates. - Records the status of each completed download and prints errors if verbose mode is enabled.
- Creates a
def download_many(cc_list: list[str],
base_url: str,
verbose: bool,
concur_req: int) -> Counter[DownloadStatus]:
counter: Counter[DownloadStatus] = Counter()
with ThreadPoolExecutor(max_workers=concur_req) as executor: # <4>
to_do_map = {} # <5>
for cc in sorted(cc_list): # <6>
future = executor.submit(download_one, cc,
base_url, verbose) # <7>
to_do_map[future] = cc # <8>
done_iter = as_completed(to_do_map) # <9>
if not verbose:
done_iter = tqdm.tqdm(done_iter, total=len(cc_list)) # <10>
for future in done_iter: # <11>
try:
status = future.result() # <12>
except httpx.HTTPStatusError as exc: # <13>
error_msg = 'HTTP error {resp.status_code} - {resp.reason_phrase}'
error_msg = error_msg.format(resp=exc.response)
except httpx.RequestError as exc:
error_msg = f'{exc} {type(exc)}'.strip()
except KeyboardInterrupt:
break
else:
error_msg = ''
if error_msg:
status = DownloadStatus.ERROR
counter[status] += 1
if verbose and error_msg:
cc = to_do_map[future] # <14>
print(f'{cc} error: {error_msg}')
return counter
Key Changes
-
Concurrency with
ThreadPoolExecutor:- Uses
ThreadPoolExecutorto download flags concurrently. - The number of concurrent threads (requests) is controlled by the
concur_reqparameter (default is 30).
- Uses
-
futures.as_completed:- Submits tasks to the thread pool and tracks each task (download) using a
Future. - The
as_completedfunction returns completed tasks one by one, allowing for real-time progress updates withtqdm.
- Submits tasks to the thread pool and tracks each task (download) using a
Key Differences Between Sequential and Threaded Versions
-
Concurrency:
- Sequential: Downloads flags one by one.
- Threaded: Downloads flags concurrently using a thread pool.
-
Progress Display:
- Sequential: Progress bar updates after each flag is downloaded sequentially.
- Threaded: Progress bar updates as tasks are completed, allowing for more dynamic feedback.
-
Error Handling:
- Both versions handle errors similarly, but in the threaded version, each
Futureis mapped to a country code to provide more context when an error occurs.
- Both versions handle errors similarly, but in the threaded version, each
-
Performance:
- The threaded version is much faster, especially with high concurrency levels, as multiple downloads happen simultaneously.