concurrency vs parallelism: Main difference & use cases 2026
TL;DR
Concurrency and parallelism solve different problems. Concurrency is about structuring a program to handle many tasks that overlap in time; parallelism is about literally executing multiple computations at the same instant on separate hardware.
In Python, threading and asyncio give you concurrency without extra CPU cores. Both rely on a task yielding control during an I/O wait rather than genuinely running Python bytecode at the same instant as another thread.
The GIL stops Python threads from parallelizing CPU-bound code. A live benchmark in this guide shows four CPU-bound tasks taking about as long threaded as run sequentially (5.45s vs. 5.22s), while multiprocessing cut that to 2.60s across two cores.
For I/O-bound work, threading and asyncio deliver the same win. The same benchmark shows eight 0.5-second waits dropping from 4.00s sequential to 0.50s with either ThreadPoolExecutor or asyncio.gather.
Python 3.13+ ships an officially supported, non-default free-threaded build that disables the GIL — the first time CPython threads can genuinely parallelize CPU-bound code, with the caveat that some C-extension packages still force the GIL back on.
The right choice is decided by the task, not a preference. CPU-bound work needs multiprocessing (or a free-threaded build) for real parallelism; I/O-bound work gets full concurrency from threading or asyncio without needing more cores at all.
Concurrency vs. parallelism: the actual definitions
Concurrency is a way of structuring a program so that multiple tasks can be in progress at overlapping times, even if only one of them is actually executing at any given instant; parallelism is multiple computations physically running at the same instant on separate processing units. Rob Pike's formulation, from a talk hosted on Go's own engineering blog, draws the line precisely: concurrency is the composition of independently executing processes, while parallelism is the simultaneous execution of computations — concurrency is about dealing with lots of things at once, parallelism is about doing lots of things at once.
The classic analogy holds up well: one cashier who switches between three customers — scanning an item for customer A, answering a question from customer B, bagging groceries for customer C — is concurrency. Three cashiers, each fully dedicated to one customer at the same moment, is parallelism. A single-core machine can run a highly concurrent program (the operating system interleaves many threads) without ever achieving parallelism, and a multi-core machine can run parallel code that isn't concurrent at all (four independent, non-overlapping computations that just happen to run on four cores back-to-back). The two properties are independent of each other, not two points on the same scale.
Where Python draws the same line: threading, multiprocessing, and asyncio
Python's own standard library documentation organizes its concurrency tools around exactly this split: "the appropriate choice of tool will depend on the task to be executed (CPU bound vs IO bound)." threading and asyncio both give a Python program concurrency — many tasks appear to progress at once — without requiring more than one CPU core. multiprocessing is what gives a Python program actual parallelism, because it runs separate OS processes, each with its own Python interpreter and memory space, on separate cores.
The reason threading alone can't turn into parallelism for CPU-bound work is the Global Interpreter Lock: CPython's own documentation states plainly that "only one thread can execute Python code at once," and recommends multiprocessing or concurrent.futures.ProcessPoolExecutor for CPU-bound workloads on multi-core machines, while noting that "threading is still an appropriate model if you want to run multiple I/O-bound tasks simultaneously." That one sentence is the entire decision framework for classic (GIL-enabled) CPython, and the benchmark below shows exactly why.
import time
import threading
import multiprocessing
defcpu_task(n): total =0for i inrange(n): total += i * i
return total
N =20_000_000WORKERS =4defrun_threaded(): threads =[threading.Thread(target=cpu_task, args=(N,))for _ inrange(WORKERS)]for t in threads: t.start()for t in threads: t.join()defrun_multiprocessing():with multiprocessing.Pool(processes=WORKERS)as pool: pool.map(cpu_task,[N]* WORKERS)
Run on a 2-core machine, four CPU-bound tasks (20 million loop iterations each) took 5.22s run one after another, 5.45s run on four threads (no improvement — if anything, slightly worse from thread-switching overhead), and 2.60s run across a 4-worker process pool, roughly matching the 2 physical cores available. Threading added concurrency (four tasks technically in flight) without adding any parallelism (nothing actually finished faster), which is the GIL's documented effect in practice, not just in theory.
Take a Quick Look
Concurrency fixes how fast your code issues requests, but a scraping or monitoring job that fires hundreds of concurrent requests from one IP just gets rate-limited faster — Nstproxy's rotating residential gateway spreads that same concurrent traffic across a pool of exit IPs instead of one.
I/O-bound work tells the opposite story, because a blocking wait — a socket read, a time.sleep, a database round trip — releases the GIL regardless of whether the code is threaded or async. The benchmark below simulates that wait without depending on external network access, since a blocking sleep and a blocking socket read release control the same way:
import time
import asyncio
from concurrent.futures import ThreadPoolExecutor
TASKS =8DELAY =0.5defio_task(): time.sleep(DELAY)asyncdefio_task_async():await asyncio.sleep(DELAY)defrun_threaded():with ThreadPoolExecutor(max_workers=TASKS)as pool:list(pool.map(lambda_: io_task(),range(TASKS)))asyncdefrun_asyncio_main():await asyncio.gather(*(io_task_async()for _ inrange(TASKS)))
Eight 0.5-second waits took 4.00s run sequentially, and 0.50s run either threaded or with asyncio.gather — both approaches achieved the full 8x speedup available, because none of the eight tasks needed the CPU while they were waiting. This is the practical reason most Python networking code (web scraping, API polling, proxy-backed request pools) reaches for threading or asyncio rather than multiprocessing: the bottleneck is the network round trip, not the CPU, so there's no CPU-bound work for extra processes to parallelize.
Does Python 3.13+ free-threading change the rule?
Starting with Python 3.13, CPython supports free threading, an officially supported build variant where the GIL is disabled by default, and that support continues in 3.14 — the first time in CPython's history that threads have been able to genuinely execute Python bytecode in parallel. It is not, however, the default build: the standard installers still ship the GIL-enabled interpreter, and a free-threaded build can re-enable the GIL at runtime via PYTHON_GIL=1 or python -X gil=1. The official free-threading guide also flags the current tradeoff directly: "some third-party packages, in particular ones with an extension module, may not be ready for use in a free-threaded build, and will re-enable the GIL," which means a library built against the classic C API can silently put a free-threaded program back into GIL-limited territory. For 2026, the practical takeaway is that the CPU-bound-work-needs-multiprocessing rule above still holds for the vast majority of production Python running the standard build, but it is no longer a permanent architectural limit — teams doing CPU-bound, thread-heavy numerical work have a real (if still-maturing) alternative to evaluate.
Cost and operational tradeoffs
Threads and async tasks share one process's memory space, which keeps their overhead low — spinning up a thread costs a fraction of the memory a full OS process needs — but that shared memory is also what makes threaded code prone to race conditions on any object more than one thread mutates, and why debugging a concurrency bug is harder than debugging a straight-line one: the failure depends on timing, not just input. Processes avoid that shared-memory hazard because each one gets its own interpreter and memory space, but that isolation is exactly why multiprocessing costs more to start and more to communicate through — passing data between processes means serializing it (via pickle by default), not just handing over a reference.
Async code avoids OS thread overhead entirely (a Python coroutine is far lighter than an OS thread), which is why asyncio-based servers can hold open far more concurrent connections than a thread-per-connection design of the same size, but that efficiency comes with a rule of its own: a single blocking, non-async call inside an async def function stalls the entire event loop, not just that one task, since there is only one thread running the event loop in the first place. Pool sizing matters for all three: an unbounded ThreadPoolExecutor or process pool can exhaust memory or file descriptors under real load just as easily as it can under-parallelize a workload that's too small to need it, so the pool size should track the actual bottleneck (CPU cores for multiprocessing, a tested concurrency ceiling for threads and async tasks) rather than an arbitrary default.
Scenario analysis: where each one actually wins
A CPU-bound job — image resizing across a batch of files, numerical simulation, parsing and transforming a large in-memory dataset — is parallelism's scenario: more cores doing the same fixed amount of work finishes it faster, and multiprocessing.Pool or concurrent.futures.ProcessPoolExecutor is the standard-library tool for it. An I/O-bound job — calling a dozen APIs, reading many files, or issuing concurrent HTTP requests against a target site — is concurrency's scenario: the bottleneck is waiting on something external, not computing something, so asyncio or a ThreadPoolExecutor reaches the same ceiling that adding more CPU cores never would.
Network-heavy Python workloads — scraping, price monitoring, ad verification, bulk API polling — sit squarely in the second category, and that's also where a purely code-level fix runs into a non-code limit: a target site or API doesn't see "one program issuing concurrent requests," it sees however many requests per second are arriving from a given IP, and most sites throttle or block on exactly that basis regardless of how efficiently the client code is structured. Concurrency controls how fast a Python program can issue requests; it has no influence over how many distinct source IPs those requests appear to come from. That's a separate axis a scraping or monitoring pipeline eventually has to solve — either accept a rate ceiling per IP, or route concurrent requests across a pool of IPs so the traffic doesn't concentrate on one address.
Nstproxy is a proxy infrastructure provider built for that second axis: a residential proxy gateway that spreads outbound requests across a large IP pool instead of one address, aimed at teams whose Python concurrency code is already efficient but still hitting per-IP rate limits. Its Residential Lite line fits a team just adding IP rotation to an existing concurrent scraper: prepaid packages starting at 10GB for $10 (about $1.00/GB), backed by a pool the provider states at 50M+ residential IPs across 200+ countries and regions with a stated 99.5% success rate, with no subscription auto-renewal to manage. The tradeoff worth knowing before adopting it: Residential Lite is priced for throughput-and-cost-sensitive jobs, not for single-request latency, so a workload that needs the fastest possible individual response — rather than the highest sustainable concurrent volume — should weigh that against a premium residential or datacenter line instead.
One rotating gateway, not a self-managed IP list — a fixed host:port with server-side rotation removes the IP-list bookkeeping that would otherwise sit next to the concurrency code itself.
Scales with the concurrency level already in the code — since the gateway rotates independently of the client's threading/asyncio model, adding more concurrent workers doesn't require separately provisioning more proxy infrastructure logic.
HTTP, HTTPS, and SOCKS5 support — works with the same requests/aiohttp proxy configuration patterns used in any of the concurrency approaches above, so switching concurrency models doesn't require switching proxy integration code.
Decision guide
Ask what the job is actually waiting on. If the answer is "the CPU, computing something," parallelism is the lever: use multiprocessing or ProcessPoolExecutor, size the pool to the physical core count, and expect near-linear speedup up to that limit. If the answer is "an external response — a network call, a disk read, another service," concurrency is the lever: use asyncio for a large number of lightweight, mostly-network tasks, or threading/ThreadPoolExecutor when the code calling into blocking, non-async libraries can't be rewritten around await. If a workload genuinely has both a CPU-heavy stage and an I/O-heavy stage — parsing a large response body after fetching it, for instance — combining asyncio for the fetch stage with a ProcessPoolExecutor for the parse stage is a documented, supported pattern rather than a workaround.
Conclusion
Concurrency and parallelism answer different questions — how a program is structured to handle overlapping work, versus how many computations physically execute at the same instant — and Python's standard library keeps that distinction intact: threading/asyncio for concurrency, multiprocessing for parallelism, with the GIL as the specific, documented reason classic CPython needs that split at all. The benchmarks above show the split isn't theoretical: the same four tasks that got no benefit from threading dropped to less than half the runtime under multiprocessing, and the same eight I/O waits got the full benefit from either threading or asyncio without touching a second core.
Q: Is Python's asyncio concurrency or parallelism?
Asyncio is concurrency, not parallelism — a single-threaded event loop runs one coroutine at a time and switches to another whenever the current one awaits an I/O operation, so many tasks progress in overlapping time windows without any of them executing Python code at the literal same instant.
Q: Does multiprocessing use more memory than threading?
Yes — each process spawned by multiprocessing gets its own Python interpreter and memory space, which costs substantially more than a thread (which shares its parent process's memory), and that overhead is why multiprocessing is worth its cost for CPU-bound work but rarely worth it for I/O-bound work that threading or asyncio already handles efficiently.
Q: Can you have parallelism without concurrency, or concurrency without parallelism?
Yes to both — four independent, non-overlapping batch jobs run back-to-back on four different cores is parallelism without concurrency (nothing overlaps in time even though multiple cores are used), and a highly concurrent single-core program that interleaves hundreds of threads is concurrency without parallelism (nothing executes at the literal same instant).
Q: Does Python 3.13+ free-threading remove the GIL limitation?
Partially — free threading is an officially supported, non-default CPython build (continuing in 3.14) that disables the GIL and lets threads genuinely execute in parallel, but it isn't the default installer, can be re-enabled at runtime, and some C-extension packages still force the GIL back on, so the classic threading-can't-parallelize-CPU-work rule still applies to most production Python today.
Q: Should I use threading or asyncio for a Python web scraper?
Either works for pure I/O-bound scraping and the benchmark above shows them performing about the same; asyncio tends to scale to a larger number of concurrent connections with less overhead, while threading is often simpler to adopt when the scraper already depends on synchronous, non-async libraries.
Q: Why does my concurrent scraper still get rate-limited even after switching to asyncio?
Because concurrency only controls how quickly your program issues requests, not how many distinct IP addresses those requests appear to come from — a target site rate-limiting by source IP will throttle a fast async client exactly as it would a slow sequential one unless the requests are also distributed across multiple outbound IPs.
Marcus Chen
Aug. 6th 2026
110M+ real IPs with 99.9% access success
Blazing-fast average response ~0.5s for high-concurrency tasks
From only $0.1/GB
Get immediate access to premium residential, datacenter, IPv6 and ISP proxy pools.