Python notes

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.Executor Classes:
    • 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

  1. flags.py (Sequential):

    • Downloads images one after the other, waiting for each to complete before requesting the next.
  2. flags_threadpool.py (Concurrent with Threads):

    • Uses concurrent.futures.ThreadPoolExecutor to download multiple images concurrently.
  3. flags_asyncio.py (Concurrent with Asyncio):

    • Uses asyncio to handle concurrent downloads without threads, relying on asynchronous coroutines.

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.py and flags_asyncio.py) due to the parallel nature of the downloads.
  • 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.

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)
  1. 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()
  2. 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 using httpx.
    • download_many(cc_list):
      • Iterates through the country codes sequentially, downloads each flag, and saves it.
      • Displays the country codes as they are processed.
  3. Main Execution:

    • The main function records the start time, calls download_many to download all flags, and prints the total time taken.
  4. 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)
  1. Reuse of Functions:

    • save_flag and get_flag are reused from the sequential script.
  2. Functions:

    • download_one(cc): Downloads and saves a single flag image (similar to the body of the for loop in the sequential version).
    • download_many(cc_list):
      • Uses ThreadPoolExecutor to execute download_one concurrently across multiple threads.
      • executor.map(download_one, sorted(cc_list)) runs the download tasks concurrently for the sorted list of country codes.
  3. 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.
  4. Performance:

    • Concurrent: The script downloads multiple flags simultaneously, reducing total download time to around 1.4 seconds.
  5. Key Takeaways

    • Concurrency Simplified:

      • concurrent.futures.ThreadPoolExecutor makes 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 Future represents 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

  1. done() Method:

    • Returns a Boolean indicating if the computation has finished (non-blocking).
  2. add_done_callback():

    • Attaches a callback that gets called when the future is complete.
  3. result() Method:

    • For concurrent.futures.Future: Blocks until the result is ready, or raises TimeoutError if a timeout is provided and exceeded.
    • For asyncio.Future: No timeout support; you typically use await to get the result.

Futures in Action

  • executor.submit():

    • Schedules a callable to run and returns a Future object representing the pending result.
    • The future’s state changes once the computation finishes, and you can retrieve the result.
  • futures.as_completed():

    • Takes an iterable of futures and returns an iterator that yields each future as it completes.

Use futures explicitly

  1. 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.
  2. 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.

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, or finished. 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.futures and asyncio, providing a way to manage and track asynchronous operations.
  • executor.map is simpler but hides futures, while futures.as_completed gives more transparency and control, showing futures in action.

Launching Processes with concurrent.futures

  • Purpose: The ProcessPoolExecutor class in concurrent.futures allows 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()
  1. Simplified Imports:

    • No need to manually handle multiprocessing, queues, or workers. All process management is handled by ProcessPoolExecutor.
  2. 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.
  3. Process Management:

    • Uses ProcessPoolExecutor with an optional max_workers parameter 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() if max_workers is not provided.
  4. 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 ProcessPoolExecutor simplifies 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, ProcessPoolExecutor offers 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()
  1. display(*args):

    • A helper function that prints messages with a timestamp for easy tracking of when events occur.
  2. loiter(n):

    • A function that simulates a task doing nothing for n seconds (sleep(n)), and then returns n * 10 as the result.
    • The function prints a message before and after sleeping, indented by n tabs for clarity.
  3. main():

    • Creates a ThreadPoolExecutor with 3 threads (workers).
    • Uses executor.map(loiter, range(5)) to submit 5 tasks (each calling loiter with values from 0 to 4).
    • The results of executor.map are retrieved in order, even though the tasks may complete at different times.

Execution Flow

  • The executor.map function 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 map call 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
  1. 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 results object is a generator, and the program prints that fact, confirming no blocking has occurred yet.
  2. Results collection:
    • loiter(1) finishes after 1 second, making the thread available for loiter(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.map returns a generator:

    • The map method 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 for loop to potentially block while waiting for the result.
  • Non-blocking task submission, blocking result retrieval:

    • Task submission to the ThreadPoolExecutor does not block execution, but retrieving results from the generator blocks until each result is ready.
  • 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.map is easy to use, it executes the same callable on different arguments and returns results in the submission order.
  • Using executor.submit and futures.as_completed offers 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 tqdm package. There are three versions:
    • flags2_sequential.py: Sequential HTTP client with error handling and progress bar.
    • flags2_threadpool.py: Concurrent HTTP client using ThreadPoolExecutor, with progress and error handling.
    • flags2_asyncio.py: Async HTTP client using asyncio and httpx, with similar functionality.

Common Features

  1. Progress Bar:

    • The tqdm package is used to display an animated progress bar for download progress.
    • It integrates with the as_completed function (either futures.as_completed or asyncio.as_completed) to update progress as tasks complete.
  2. 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.
  3. 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).

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

  1. 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.
  2. 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 tqdm progress 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 ThreadPoolExecutor or asyncio with 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 tqdm library to display a progress bar unless in verbose mode.
  • Error Handling:
    • Catches exceptions like HTTPStatusError (for HTTP status code errors), RequestError (for other network-related errors), and KeyboardInterrupt (for user interrupts).
    • For any exception, the error message is stored and displayed if verbose mode is enabled.
  • Flow:
    1. Iterates over the country code list.
    2. Downloads each flag sequentially, updating the progress bar or printing verbose output.
    3. Records the status of each download (e.g., OK, NOT_FOUND, ERROR).
    4. Returns a Counter summarizing 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>
  1. get_flag(base_url, cc):

    • Downloads a flag image from a URL constructed using the country code (cc).
    • Uses the httpx library to perform the HTTP request with a timeout and redirect handling.
    • Raises an HTTPStatusError if the response status code is not in the 200 range.
  2. download_one(cc, base_url, verbose=False):

    • Calls get_flag to download the flag for a given country code.
    • Catches HTTPStatusError to specifically handle 404 (Not Found) errors, marking the download status as NOT_FOUND.
    • If successful, saves the flag image and returns a status of OK.
    • Optionally prints verbose output showing the status of each download.

Threaded Download flags2_threadpool.py

  • Purpose: Downloads multiple flags concurrently, tracking their statuses and errors.
  • Progress Bar:
    • Uses tqdm to update the progress bar as each Future is completed.
  • Error Handling:
    • Handles HTTP and network-related errors similarly to the sequential version, but maps each Future to its corresponding country code using a to_do_map.
  • Flow:
    1. Creates a ThreadPoolExecutor with a limit on concurrent requests.
    2. Submits download tasks (download_one) to the executor and tracks each Future.
    3. Uses as_completed to retrieve results as tasks complete, allowing the progress bar to reflect real-time updates.
    4. Records the status of each completed download and prints errors if verbose mode is enabled.
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

  1. Concurrency with ThreadPoolExecutor:

    • Uses ThreadPoolExecutor to download flags concurrently.
    • The number of concurrent threads (requests) is controlled by the concur_req parameter (default is 30).
  2. futures.as_completed:

    • Submits tasks to the thread pool and tracks each task (download) using a Future.
    • The as_completed function returns completed tasks one by one, allowing for real-time progress updates with tqdm.

Key Differences Between Sequential and Threaded Versions

  1. Concurrency:

    • Sequential: Downloads flags one by one.
    • Threaded: Downloads flags concurrently using a thread pool.
  2. 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.
  3. Error Handling:

    • Both versions handle errors similarly, but in the threaded version, each Future is mapped to a country code to provide more context when an error occurs.
  4. Performance:

    • The threaded version is much faster, especially with high concurrency levels, as multiple downloads happen simultaneously.