Pinging Hosts Sequentially vs Concurrently in Python

I’m reorganizing some things in my homelab and wanted to check how many hosts are alive in a /24. The simple check, while not perfect, is to ping all the hosts and see who responds. I used Claude to generate some Python for me and noticed it used some tools I had not used before, to run code concurrently, which really speeds up things like scanning a /24 for alive hosts. In this post I’ll compare running sequentially vs concurrently and break down the code.

Running something sequentially means that every job, such as checking if an IP is alive, must run to completion before the next job starts. This is highly inefficient, of course, mainly for two reasons:

  • Work can’t be performed in parallel
  • Most of the time is spent waiting, rather than working

Especially when the job involves something like ping, we must give hosts a reasonable time to respond, so we’ll wait up to a second. We also can’t one-shot the ping because what if we have no ARP entry for the host we are pinging (assuming same subnet)? Then, a host that is alive could be reported as dead due to the missing ARP entry.

I’ll give you the results from the two scripts and then we’ll look at the code. First, the sequential script:

python3 ping_script_sequential.py 
Scanning 254 hosts in 192.168.1.0/24...

[  1/254] 192.168.128.1      UP  ✓
[  2/254] 192.168.128.2      DOWN ✗
[  3/254] 192.168.128.3      DOWN ✗
<snip>
=============================================
SCAN COMPLETE — 192.168.128.0/24
=============================================

✅ Reachable (7):
   192.168.128.1
   192.168.128.131
   192.168.128.138
   192.168.128.140
   192.168.128.143
   192.168.128.193
   192.168.128.235

❌ Unreachable (247):
   192.168.128.2
   192.168.128.3
   192.168.128.4
<snip>
Total: 7 up / 247 down out of 254 hosts
Time:  512.99 seconds

Now the concurrent script:

❯ python3 ping_script_concurrent.py 
Scanning 254 hosts in 192.168.128.0/24...

[  1/254] 192.168.128.1      UP  ✓
[  2/254] 192.168.128.31     DOWN ✗
[  3/254] 192.168.128.24     DOWN ✗
<snip>
=============================================
SCAN COMPLETE — 192.168.128.0/24
=============================================

✅ Reachable (7):
   192.168.128.1
   192.168.128.131
   192.168.128.138
   192.168.128.140
   192.168.128.143
   192.168.128.193
   192.168.128.235

❌ Unreachable (247):
   192.168.128.2
   192.168.128.3
   192.168.128.4
<snip>
Total: 7 up / 247 down out of 254 hosts
Time:  11.26 seconds

The sequential script looks like this:

import subprocess
import ipaddress
import sys
import time

def ping_host(ip: str) -> tuple[str, bool]:
    """Ping a single host and return (ip, is_reachable)."""
    result = subprocess.run(
        ["ping", "-c", "2", "-W", "1", str(ip)],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    return str(ip), result.returncode == 0

def ping_network(network_cidr: str):
    network = ipaddress.ip_network(network_cidr, strict=False)
    hosts = list(network.hosts())

    print(f"Scanning {len(hosts)} hosts in {network}...\n")

    reachable = []
    unreachable = []
    start = time.perf_counter()

    for i, ip in enumerate(hosts, 1):
        ip, is_up = ping_host(str(ip))
        status = "UP  ✓" if is_up else "DOWN ✗"
        print(f"[{i:>3}/{len(hosts)}] {ip:<18} {status}")
        (reachable if is_up else unreachable).append(ip)

    elapsed = time.perf_counter() - start

    reachable.sort(key=lambda ip: ipaddress.ip_address(ip))
    unreachable.sort(key=lambda ip: ipaddress.ip_address(ip))

    print("\n" + "=" * 45)
    print(f"SCAN COMPLETE — {network}")
    print("=" * 45)
    print(f"\n✅ Reachable ({len(reachable)}):")
    for ip in reachable:
        print(f"   {ip}")

    print(f"\n❌ Unreachable ({len(unreachable)}):")
    for ip in unreachable:
        print(f"   {ip}")

    print(f"\nTotal: {len(reachable)} up / {len(unreachable)} down out of {len(hosts)} hosts")
    print(f"Time:  {elapsed:.2f} seconds")

if __name__ == "__main__":
    target = sys.argv[1] if len(sys.argv) > 1 else "192.168.128.0/24"
    ping_network(target)

The general logic of the script is this:

  • Convert the IP network string to an IP network using the ip address module
  • Find all the hosts in this network
  • Use subprocess to ping every host sequentially
  • Print what hosts are up or down
  • Sort the lists that hold reachable and unreachable hosts
  • Print the reachable and unreachable hosts

Mainly the wait in the sequential script is because of waiting after sending a ping to see if there is a response. As we send two pings for every host, every unreachable host adds roughly two seconds of processing time. As there were only a few reachable hosts in this subnet, the total processing time is almost twice of the number of possible hosts in the subnet for a total of 513 seconds.

The concurrent script looks like this:

import subprocess
import ipaddress
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed

def ping_host(ip: str) -> tuple[str, bool]:
    """Ping a single host and return (ip, is_reachable)."""
    result = subprocess.run(
        ["ping", "-c", "2", "-W", "1", str(ip)],
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    return str(ip), result.returncode == 0

def ping_network(network_cidr: str, max_workers: int = 50):
    network = ipaddress.ip_network(network_cidr, strict=False)
    hosts = list(network.hosts())

    print(f"Scanning {len(hosts)} hosts in {network}...\n")

    reachable = []
    unreachable = []
    start = time.perf_counter()

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        futures = [executor.submit(ping_host, str(ip)) for ip in hosts]

        for i, future in enumerate(as_completed(futures), 1):
            ip, is_up = future.result()
            status = "UP  ✓" if is_up else "DOWN ✗"
            print(f"[{i:>3}/{len(hosts)}] {ip:<18} {status}")
            (reachable if is_up else unreachable).append(ip)

    elapsed = time.perf_counter() - start

    reachable.sort(key=lambda ip: ipaddress.ip_address(ip))
    unreachable.sort(key=lambda ip: ipaddress.ip_address(ip))

    print("\n" + "=" * 45)
    print(f"SCAN COMPLETE — {network}")
    print("=" * 45)
    print(f"\n✅ Reachable ({len(reachable)}):")
    for ip in reachable:
        print(f"   {ip}")

    print(f"\n❌ Unreachable ({len(unreachable)}):")
    for ip in unreachable:
        print(f"   {ip}")

    print(f"\nTotal: {len(reachable)} up / {len(unreachable)} down out of {len(hosts)} hosts")
    print(f"Time:  {elapsed:.2f} seconds")

if __name__ == "__main__":
    target = sys.argv[1] if len(sys.argv) > 1 else "192.168.128.0/24"
    ping_network(target)

Now let’s look at the main differences. In the import we have the following:

from concurrent.futures import ThreadPoolExecutor, as_completed

The concurrent.futures is a standard Python library that provides a high-level interface for running code concurrently. A Future is an object that represents a computation that hasn’t necessarily finished yet. We hand off a job, get a Future back immediately, and it will eventually hold the result once the work is done. We can check on it, wait for it, or use as_completed to notify us when the job is completed.

With this module, we can achieve concurrency in two ways:

  • ThreadPoolExecutor – it runs tasks in multiple threads. All threads share the same memory and run inside the Python process
  • ProcessPoolExecutor – runs tasks in multiple separate processes. Each process has its own memory and Python interpreter

Then we create a ThreadPool:

with ThreadPoolExecutor(max_workers=max_workers) as executor:

The ThreadPool is a fixed team of worker threads that are created once and then reused. Here we create 50 threads and initially they are just waiting until they get assigned work. We are using a context manager (with statement) which will run executor.shutdown(wait=True) which waits for every thread to finish before the program continues. Compare this to opening a file using a with statement where we ensure it gets closed at the end.

Then we run:

futures = [executor.submit(ping_host, str(ip)) for ip in hosts]

This is a list comprehension where we will store our Future objects in the list named futures. We call the function ping_host() and provide it with str(ip) as an argument, for every ip in our hosts list. This is going to use a worker thread and the nice thing is that a Future is returned immediately. The key design here is that submitting is non-blocking.

Why do we use threads for ping and not processes? Because ping is I/O-bound. Every thread will spend most of its time doing nothing, waiting for either a reply or a timeout. We’re not bottlenecked by the CPU.

Next we have:

for i, future in enumerate(as_completed(futures), 1):

What is as_completed? It takes a collection of our Future objects and returns an iterator that yields each Future exactly once, in the order they complete – not the order they were submitted. We could see this in the output previously which looked something like this:

[  1/254] 192.168.128.1      UP  ✓
[  2/254] 192.168.128.31     DOWN ✗
[  3/254] 192.168.128.24     DOWN ✗

192.168.128.1 was the first in the list and completed first as it was reachable, then .31 and .24 completed before .2, .3, and so on.

The nice thing here using as_completed is that everything is event-driven, there is no polling, sleeping, or wasting CPU cycles. When a thread finishes and marks its Future as done, it gets added to an internal queue and as_completed will yield the For loop when something arrives to that queue.

Next we have some tuple unpacking:

ip, is_up = future.result()

We call future.result() (it’s a method call) which does two things:

  • Blocks the current thread until the specific Future is done
  • Returns whatever the function running in the thread returned

The blocking is fine as we have already run to completion using as_completed.

Finally, we do some sorting to get the reachable and unreachable IPs in order:

reachable.sort(key=lambda ip: ipaddress.ip_address(ip))
unreachable.sort(key=lambda ip: ipaddress.ip_address(ip))

A lambda is a function defined inline without a name. Here we’re converting our IP addresses in their current string-format to be IP address objects. This allows us to do proper sorting as otherwise 192.168.1.10 would come before 192.168.1.2, for example.

All in all, we were around 45x faster thanks to running things concurrently instead of sequentially. That’s quite the difference! I hope you also learned something, just like I did, from this relatively simple script.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top