{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Concurrency models\n",
    "\n",
    "Before any syntax, one idea decides everything that follows: **what is your program actually doing while it's slow?** Almost all slowness is one of two kinds. Either your program is *waiting* — for a web server, a disk, a database — or it's *computing* — running a loop that pins a CPU core flat out. These are called **I/O-bound** and **CPU-bound** work, and they want completely different tools.\n",
    "\n",
    "This notebook builds the mental model. By the end you'll be able to look at a slow piece of code and say which of Python's three concurrency tools — threads, processes, or `async`/`await` — is the right one, and why."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Concurrency is not parallelism\n",
    "\n",
    "The two words get used interchangeably, but they mean different things, and the difference is the whole topic in miniature.\n",
    "\n",
    "- **Concurrency** is *structuring* a program as multiple tasks that can make progress independently. They take turns. At any single instant only one might be running — but because they hand off whenever one is stuck waiting, the whole set finishes sooner. One cook juggling four pans.\n",
    "- **Parallelism** is *actually doing* more than one thing at the same instant, on more than one CPU core. Four cooks, four pans, four hobs.\n",
    "\n",
    "You can have concurrency without parallelism (one core, tasks taking turns) and parallelism is just one way to *implement* concurrency. The reason this matters: Python's `async` and threads give you concurrency but, for pure computation, **not** parallelism. Only processes give you real parallelism. The next sections explain why."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## The distinction that drives everything: I/O-bound vs CPU-bound\n",
    "\n",
    "**I/O-bound** work spends most of its time *waiting* for something outside the program — a network response, a disk read, a user. The CPU is idle during the wait. If you have a hundred such tasks, they can all wait *at the same time*; the waiting overlaps for free.\n",
    "\n",
    "**CPU-bound** work spends its time *computing* — hashing, parsing, number-crunching, image processing. The CPU is busy the whole time. The only way to do more of it per second is to use more cores.\n",
    "\n",
    "Let's make the distinction concrete with two functions that each take about the same wall-clock time but for opposite reasons."
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "import time\n",
    "\n",
    "def io_task(n):\n",
    "    \"\"\"I/O-bound: mostly waiting. We simulate a network call with sleep.\"\"\"\n",
    "    time.sleep(0.2)          # the CPU does nothing during this 0.2s\n",
    "    return n\n",
    "\n",
    "def cpu_task(n):\n",
    "    \"\"\"CPU-bound: the core is busy the whole time.\"\"\"\n",
    "    total = 0\n",
    "    for i in range(2_000_000):\n",
    "        total += i * i\n",
    "    return total\n",
    "\n",
    "start = time.perf_counter()\n",
    "io_task(0)\n",
    "print(f'one io_task : {time.perf_counter() - start:.2f}s')\n",
    "\n",
    "start = time.perf_counter()\n",
    "cpu_task(0)\n",
    "print(f'one cpu_task: {time.perf_counter() - start:.2f}s')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Run four of each, one after another, and they both take roughly four times as long. Sequential code doesn't care *why* something is slow — it just does one thing, then the next."
   ]
  },
  {
   "cell_type": "code",
   "metadata": {},
   "execution_count": null,
   "outputs": [],
   "source": [
    "start = time.perf_counter()\n",
    "for n in range(4):\n",
    "    io_task(n)\n",
    "print(f'4 io_tasks sequentially : {time.perf_counter() - start:.2f}s')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "That `0.8s` is almost entirely *waiting*. Nothing was computed — the program just sat there four times. This is the slack that concurrency reclaims: while one `io_task` waits, another could be waiting too. Overlap the four waits and the whole batch takes about as long as **one** of them.\n",
    "\n",
    "The CPU-bound batch is different. There's no slack to reclaim — the core is already busy. Running four of them \"at once\" on a single core just means slicing the same core four ways; the total work, and total time, is unchanged. To finish faster you need *more cores*."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## The GIL, in one paragraph\n",
    "\n",
    "CPython has a **Global Interpreter Lock** (the GIL): a mutex that lets only one thread execute Python bytecode at a time. So Python threads do **not** run Python code in parallel — even on a 16-core machine, two threads doing pure computation take turns on one core. This sounds fatal for threads, and for CPU-bound work it is. But there's a crucial exception: a thread *releases* the GIL while it waits on I/O (and inside many C extensions). So while one thread is blocked on a socket, another thread runs. That single fact is why threads are excellent for I/O-bound work and useless for CPU-bound work. The [GIL concept essay](https://agilearn.co.uk/guides/concurrency/concepts/the-gil-and-what-it-means) goes deeper, including the new free-threaded builds that remove it."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Python's three tools, and the work each one fits\n",
    "\n",
    "With that distinction in hand, the three tools sort themselves out:\n",
    "\n",
    "| Tool | Gives you | GIL? | Best for |\n",
    "| --- | --- | --- | --- |\n",
    "| **Threads** (`threading`, `ThreadPoolExecutor`) | Concurrency, shared memory | Held — one thread runs Python at a time | **I/O-bound** work, a moderate number of tasks |\n",
    "| **Processes** (`multiprocessing`, `ProcessPoolExecutor`) | True parallelism, isolated memory | Sidestepped — each process has its own | **CPU-bound** work |\n",
    "| **`async`/`await`** (`asyncio`) | Concurrency, one thread, cooperative | Held — but irrelevant; one thread | **I/O-bound** work at high volume (thousands of connections) |\n",
    "\n",
    "Notice that **two** of the three tools target I/O-bound work. That's not redundancy: threads suit a few dozen blocking calls and existing blocking libraries; `async` suits thousands of connections and code written to cooperate. Only processes target CPU-bound work, because only processes give you more than one core's worth of Python."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## A decision in three questions\n",
    "\n",
    "1. **Is the work CPU-bound or I/O-bound?** If the core is busy the whole time, it's CPU-bound → reach for **processes**. Everything else is I/O-bound.\n",
    "2. **For I/O-bound work, how many tasks at once?** A handful to a few hundred, often using ordinary blocking libraries → **threads** (`ThreadPoolExecutor`) are simplest. Many hundreds to thousands of simultaneous connections → **`async`**.\n",
    "3. **Do you control the code being called?** `async` only helps if the libraries you call are themselves async (or you push blocking calls to a thread). If you're stuck with blocking libraries, threads are the pragmatic choice.\n",
    "\n",
    "A frequent and correct answer is **\"none yet\"**: if the program is fast enough, adding concurrency only adds bugs. Concurrency is a tool for a measured problem, not a default."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## What's next\n",
    "\n",
    "The remaining three notebooks take the tools in order of how often you'll reach for them:\n",
    "\n",
    "- **[Threads and futures](https://agilearn.co.uk/guides/concurrency/learn/02-threads-and-futures)** — the everyday I/O-bound workhorse, via the friendly `concurrent.futures` interface.\n",
    "- **[Processes and parallelism](https://agilearn.co.uk/guides/concurrency/learn/03-processes-and-parallelism)** — real multi-core computation, and the rules processes impose.\n",
    "- **[Async and await](https://agilearn.co.uk/guides/concurrency/learn/04-async-await)** — single-threaded concurrency for high-volume I/O.\n",
    "\n",
    "Keep the I/O-versus-CPU question in your head as you go; every design choice in the next three notebooks traces back to it."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.10"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}