Proxy 101
July 29, 2026
7 min

Concurrency vs Parallelism: What They Are and When to Use Each

Alex Sadovskij
Alex Sadovskij
CEO Proxy-Cheap
Concurrency vs Parallelism: What They Are and When to Use Each
概要
Concurrency vs parallelism explained with clear examples, a Python benchmark, and how to pick the right model for I/O-bound and CPU-bound work.

Concurrency and parallelism sound like synonyms, and developers regularly reach for the wrong one: spinning up extra processes for work that was only ever waiting, or expecting threads to speed up a calculation Python will not run in parallel.

The distinction is simpler than the debate around it. Concurrency is structure: one processor interleaves several tasks so they all make progress over the same stretch of time. Parallelism is execution: multiple cores run tasks at the same instant. The useful question is rarely which is "better," but whether your work is I/O-bound (mostly waiting on a network or disk) or CPU-bound (mostly computing).

This guide works through that decision with clear examples, gets specific about Python (asyncio, threading, multiprocessing, and the GIL), backs it with a real benchmark, and ends with what changes when you scale concurrent requests past a single machine.

Quick Answer (TL;DR)

Concurrency and parallelism sound similar and both run multiple tasks, but they solve different problems. Concurrency is about structure: one processor switches between tasks so several keep making progress over overlapping time periods. Parallelism is about execution: multiple cores or processors run tasks at the same instant. In short, concurrency deals with many tasks at once; parallelism does many tasks at once. Which one you need depends on whether your work is I/O-bound or CPU-bound.

Key takeaways

Concurrency is about structure (one processor switching between tasks so several make progress); parallelism is about execution (multiple cores running tasks at the same instant).

The practical question is rarely "which is better." It is "is my work I/O-bound or CPU-bound," which decides the model for you.

In Python, the GIL means threads do not give true parallelism for CPU work; use multiprocessing for CPU-bound tasks and asyncio or threading for I/O-bound tasks.

For network-heavy workloads (many HTTP requests at once), concurrency wins, and the bottleneck shifts from your CPU to your connection limits and per-host request rate.

Concurrency vs parallelism at a glance

Concurrency manages multiple tasks by interleaving them on shared resources, so they overlap in time without necessarily running at the same instant. Parallelism splits work across multiple cores so tasks run literally simultaneously. Concurrency improves responsiveness and throughput on I/O-bound work; parallelism improves speed on CPU-bound work. A program can be concurrent, parallel, both, or neither.

The cleanest way to keep them apart is structure versus execution. Concurrency is how you organize a program into independent tasks that can make progress on a single processing unit: a single-core CPU switches between them, running only one task at any instant in an interleaved manner. Parallelism needs multiple processing units. It is whether those tasks actually run at exactly the same time, which depends on having more than one core. Tasks that run concurrently do not always run simultaneously. The two are not opposites, and they combine: a parallel concurrent program splits independent tasks across separate cores at once, parallel execution layered on a concurrent structure.

AspectConcurrencyParallelism
Core ideaStructure: one worker interleaves many tasksExecution: many workers run tasks at once
Hardware neededWorks on a single coreNeeds multiple cores; parallelism requires hardware
What it optimizesResponsiveness and throughputRaw computational speed
Typical workloadI/O-bound (waiting on network, disk)CPU-bound (heavy computation)
Python toolasyncio, threadingmultiprocessing
Everyday analogyOne barista starting the next order while a drink brewsSeveral baristas each making a drink at once

I/O-bound vs CPU-bound: the decision that actually matters

Most of the "which model" debate disappears once you classify the work. A task is I/O-bound when it spends most of its time waiting on something external: a network response, a disk read, a database query, or user input. The CPU sits idle during that wait, so the program can work on other tasks and remain responsive meanwhile. A task is CPU-bound when it spends most of its time computing: hashing, parsing large documents, resizing images, data processing, data analysis, running math. There is no waiting to overlap, only calculation to spread out.

That classification maps straight onto the model. I/O-bound work fits concurrency, because while one task waits, another can use the same core. Use asyncio or threads. CPU-bound work fits parallelism, because the only way to go faster is to run on more cores at once. Use multiple processes. Fetching 500 URLs for data collection at scale is I/O-bound: the program is mostly waiting on responses. A web server handling multiple user requests is the same shape: each request mostly waits, so one worker can handle multiple tasks simultaneously by overlapping those waits. Resizing 500 images or parsing a huge HTML payload is CPU-bound: the program is mostly computing.

The trap runs in both directions. Apply parallelism to I/O-bound work and you pay for process startup and inter-process communication to speed up tasks that were only ever waiting. Apply threads to CPU-bound work in Python and you gain almost nothing: even on a multi-core CPU, the Global Interpreter Lock keeps your threads from computing in parallel.

A short way to decide:

Profile first. Is the task waiting or computing?

If it is waiting, raise concurrency. More async tasks or threads.

If it is computing, add cores. More processes.

Concurrency and parallelism in Python

Python gives you three tools, and each one fits a different shape of work. Picking the right one is mostly a matter of knowing what each was built for.

threading runs multiple threads inside one process with shared memory. It suits I/O-bound work. While a thread waits on a network response or a disk read, it releases the GIL, so other threads keep moving. Because threads share memory, code updating the same data from more than one task needs synchronization mechanisms (locks, atomic operations) to avoid race conditions. For CPU-bound work threading is limited, because only one thread runs Python bytecode at a time.

multiprocessing runs separate processes, each with its own interpreter and memory, on separate cores. Because every process has its own GIL, this gives true parallelism for CPU-bound work: the application splits the job into smaller subtasks and runs multiple computations on different cores for higher computational speed. The cost is higher overhead: starting processes and moving data between them is heavier than spawning threads.

asyncio runs a single thread with cooperative multitasking. You mark functions with async/await, and an event loop switches to another task whenever the current one is waiting, without the operating-system context switching that threads incur. It is the strongest fit for high-volume I/O-bound concurrency, where you want thousands of requests in flight without thousands of threads.

Sitting above threads and processes, concurrent.futures offers one high-level interface (ThreadPoolExecutor and ProcessPoolExecutor) so you can switch between the two within the same application without rewriting much.

Python's Global Interpreter Lock lets only one thread execute Python bytecode at a time. It simplifies memory management but prevents threads from giving true parallelism on CPU-bound work. I/O-bound threads release the GIL while waiting, which is why threading still helps for network-heavy tasks.

Here is the asyncio pattern for fetching many URLs at once. The event loop fires off every request and gathers the results as they return:

python import asyncio import aiohttp async def fetch(session, url):    async with session.get(url) as response:        return await response.text() async def fetch_all(urls):    async with aiohttp.ClientSession() as session:        tasks = [fetch(session, url) for url in urls]        return await asyncio.gather(*tasks) results = asyncio.run(fetch_all(urls))

And here is the multiprocessing pattern for CPU-bound work, spreading a heavy function across cores:

python from concurrent.futures import ProcessPoolExecutor def hash_chunk(chunk):    # CPU-bound work, for example hashing or parsing    ... with ProcessPoolExecutor() as executor:    results = list(executor.map(hash_chunk, chunks))

If your requests run through a gateway, the same asyncio.gather structure works when you are managing requests through a proxy API, and the choice of IP type matters as much as the code, which is covered in our Proxy Types article.

For a deeper reference on the async model, see Python's asyncio documentation, and for the origin of the structure-versus-execution framing, see Rob Pike's "Concurrency is not Parallelism" talk guide.

Benchmark: how much faster is concurrent code, really?

 

Numbers make the difference concrete. We ran the same I/O-bound fan-out four ways and timed each one. The workload was 120 requests with a mean latency of about 97 milliseconds, on Python 3.12.3. The thread pool used 50 workers; the asyncio run held all 120 requests in flight at once.

ApproachWall-clock timeSpeedup vs sequential
Sequential11.68 s1x
Threading (50 workers)0.33 s~35x
Asyncio (all concurrent)0.16 s~73x

Doing the requests one after another took almost 12 seconds of mostly waiting. Concurrency collapsed that to a fraction of a second. The gap between threading and asyncio here is a function of concurrency level, not a per-task penalty: the thread pool ran 50 at a time, while asyncio ran all 120 together. Async tasks are cheap to hold open, so you can run thousands without the memory cost of thousands of threads, which is why asyncio pulls ahead on high-volume fan-out.

CPU-bound work inverts the result. We ran a hashing workload the same four ways, and threading and asyncio gave no speedup at all: roughly 3.0 seconds each, the same as running sequentially, because the GIL keeps threads from computing in parallel and asyncio is still a single thread. Multiprocessing is the model that uses extra cores for this kind of work, and its speedup scales with the core count.

ApproachCPU-bound timeNotes
Sequential3.17 sbaseline
Threading3.02 sno speedup, GIL
Asyncio3.01 sno speedup, single thread
Multiprocessingmeasure on multi-core hardwarescales with core count

The point worth remembering: the technique that is fastest for I/O (asyncio) does nothing for CPU-bound work, and the technique built for CPU-bound work (multiprocessing) is wasteful for I/O. Match the model to the workload, not to a favorite.

Scaling concurrent requests: what your network needs

Once concurrency climbs, the bottleneck moves off your CPU and onto the network and the target host. Your code can launch hundreds of requests, but other limits decide how many actually run. The first is your connection pool. Common clients ship with conservative defaults: httpx allows 100 connections (20 kept alive), and aiohttp's connector defaults to 100 total connections with no per-host cap. Launch more concurrent requests than the pool allows, and the extra ones wait in a queue until a slot frees up.

The second limit is the target host. Sending a large number of simultaneous requests to a single host can return 429 (Too Many Requests) and 503 (Service Unavailable) responses. The fix is to stay within the host's published request limits: add request throttling, honor any Retry-After header, and keep your request rate per host reasonable. Where you need higher total throughput, distribute the requests across multiple IPs, a form of load balancing, so each IP stays within a sensible per-minute budget. The more concurrent requests you aim at one host, the more IPs you want in rotation.

A rough way to size this, from concurrency level to IP-pool sizing to rate-limit budget: estimate your total requests per minute, then divide by the per-minute rate you want to hold on each IP. Say you need to sustain about 18,000 requests per minute and you want to keep each IP under 60 requests per minute. That points to a rotating pool in the region of 300 IPs (18,000 divided by 60). Tighten the per-IP budget and the pool grows; loosen it and the pool shrinks. At this scale it becomes a small distributed systems problem: many workers, many addresses, one shared rate budget.

Spreading load across IPs is where a proxy provider fits in. Proxy-Cheap runs that request-distribution layer across residential, mobile, datacenter, ISP, and IPv6 lines under one account. For variable concurrency, where your request volume changes from one job to the next, pay-as-you-go rotating residential proxies let you size the IP pool to the workload with no monthly commitment. For sustained, high-volume fan-out, unlimited bandwidth proxies take per-GB metering out of the math.

The IP type can follow the workload too. ISP proxies pair residential-grade addresses with datacenter-level speed for long-lived sessions, static residential proxies hold the same address when a host expects a stable one across a session, and datacenter proxies deliver high throughput for documentation crawls and unprotected public endpoints. If you are profiling a real workload before you scale it, start from the request pattern and let the IP count and proxy type fall out of the numbers.

Concurrency vs parallelism in other languages (quick tour)

The same distinction shows up across languages and across real-world applications, with different defaults. Go treats concurrency as a first-class language feature: goroutines are lightweight functions that run independently, and channels coordinate them. Rob Pike used this model to argue that concurrency (dealing with multiple things at once) and parallelism (doing them at once) are separate ideas.

JavaScript and Node.js run a single-threaded event loop. Concurrency comes from async/await and Promise.all, which overlap I/O-bound work the way asyncio does in Python. For CPU-bound work, Node reaches for worker threads to move computation onto separate cores for parallel processing.

Java takes a different path. It has had real thread parallelism for a long time, with no Global Interpreter Lock, and the java.util.concurrent toolkit provides thread pools, locks, and concurrent data structures. A Java program can run parallel tasks on different processors for both I/O-bound and CPU-bound work, and it stays common for heavy data processing.

2026 update: Python's free-threaded (no-GIL) builds

The GIL is no longer permanent. Python 3.13 shipped an experimental free-threaded build under PEP 703 that disables the GIL, and Python 3.14, released in October 2025, promoted that build to officially supported status under PEP 779. What this changes is significant: with the free-threaded build, threads can finally give true CPU parallelism without multiprocessing overhead, and the single-threaded performance cost has dropped to roughly 5 to 10 percent, down from about 40 percent in 3.13.

What it does not change, yet, is just as important. The free-threaded build is still opt-in, not the default, so a standard Python install runs with the GIL on. Ecosystem support is partial: if you import a C extension that has not declared itself thread-safe, the interpreter quietly re-enables the GIL for the whole process. And for I/O-bound request fan-out, asyncio remains the cleaner model. Making the free-threaded build the default is planned for a later release, possibly Python 3.16 or beyond, depending on how fast the ecosystem catches up. This area is moving quickly, so check the current status before you rely on it: PEP 703{.

常见问题解答

No, multithreading is one way to achieve concurrency; concurrency is the broader idea and can also be done with async or multiple processes.

Yes, parallel concurrent execution distributes concurrent tasks across multiple cores.

Only for I/O-bound work where tasks spend time waiting; for purely sequential CPU work it adds overhead without speedup.

The GIL allows only one thread to run Python bytecode at a time, so CPU-bound threads cannot run in parallel.

Asyncio scales better for high-volume request fan-out; threading is simpler for moderate concurrency. Both suit I/O-bound work.

When the work is CPU-bound (parsing, image processing, hashing, math), so it benefits from running on multiple cores.

It depends on your connection limits and the target host's rate limits, not just your code; high concurrency to one host usually needs requests spread across multiple IPs.

Async is a concurrency model on a single thread (cooperative task switching); parallel means running on multiple cores at the same instant.

No, for I/O-bound tasks concurrency is usually faster because parallelism adds process overhead for work that is mostly waiting.