Skip to content

Concurrency and Order


This closing chapter covers the scale, pace, and sequence of multi-host execution — four genuinely different controls, often confused with each other, each solving a different problem.

forks: How Many Hosts Run In Parallel

By default, Ansible runs tasks against up to 5 hosts simultaneously. With more hosts than that in your target group, they’re processed in batches of that size. forks controls the batch size:

ansible-playbook site.yaml --forks 20

Or persistently, recalling the configuration chapter:

ansible.cfg
[defaults]
forks = 20

Higher forks means more parallelism and a faster overall run against many hosts — but also more simultaneous load on the control node itself, and potentially on any shared resource the managed hosts touch, like a database receiving 20 simultaneous connection attempts instead of 5.

serial: Rolling Batches Instead Of All At Once

forks is about raw connection concurrency. serial is a genuinely different, play-level control: how many hosts complete the entire play before the next batch even begins.

- name: Rolling Deployment
  hosts: servers
  serial: 2
  tasks:
    - name: Deploy new version
      ansible.builtin.debug:
        msg: "Deploying to {{ inventory_hostname }}"
    - name: Verify health
      ansible.builtin.debug:
        msg: "Verifying {{ inventory_hostname }}"

With serial: 2 against 6 total hosts, Ansible runs 2 hosts through the entire task list, then the next 2, then the final 2 — never all 6 at once. This isn’t about connection speed; it’s about limiting the blast radius of a deployment.

A Realistic Example: A Rolling Deployment

Deploying a new — possibly broken — version to every web server simultaneously means a total outage the moment something’s wrong. With serial: 2 out of 6 servers, only 2 are ever mid-update at any given moment, while the other 4 keep serving traffic normally.

serial also accepts a percentage, or a list of increasing batch sizes:

serial: "25%"
serial:
  - 1
  - 3
  - "50%"

The list form is a common “start small, build confidence” pattern: one host first (the smallest possible risk), then three, then half the remainder at a time.

throttle: Limiting Parallelism For One Specific Task

forks sets overall play-wide parallelism. throttle limits parallelism for just one task, without touching every other task in the play:

- name: Call a rate-limited API
  ansible.builtin.debug:
    msg: "Calling API for {{ inventory_hostname }}"
  throttle: 1

Even with forks: 20 allowing broad concurrency generally, this specific task only ever runs on one host at a time — useful for a task hitting a rate-limited external API or another shared resource, without slowing the entire play down to match.

order:: Controlling Which Sequence Hosts Are Visited In

By default, hosts are processed in the order they appear in the inventory. order: changes this:

- name: My Play
  hosts: servers
  order: sorted
  tasks:
    - name: Example task
      ansible.builtin.debug:
        msg: "..."

The order: Options: inventory, reverse_inventory, sorted, shuffle

  • inventory (default) — the order hosts appear in the inventory source.
  • reverse_inventory — exactly the opposite.
  • sorted — alphabetical by hostname.
  • reverse_sorted — reverse alphabetical.
  • shuffle — genuinely random each run, useful for avoiding a systematic bias — if whichever host happens to be first in inventory order is subtly different in some way, always hitting it first every single run can mask or reveal issues inconsistently.

Note

inventory does not mean the order in which hosts/groups are defined in inventory file — it’s the order of selection retured from the compiled inventory by Ansible which may be reproducible but not predictible.

A Realistic Pattern: Canary Deployment With serial And run_once

Combining this chapter’s serial with the previous chapter’s run_once, plus until from the loops section and template/systemd from common modules:

- name: Canary Deployment
  hosts: servers
  serial:
    - 1
    - "100%"
  tasks:
    - name: Deploy new version
      ansible.builtin.template:
        src: app.conf.j2
        dest: /etc/myapp/app.conf
      notify: Restart app

    - name: Wait for health check to pass
      ansible.builtin.command: curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/health
      register: health_check
      until: health_check.stdout == "200"
      retries: 10
      delay: 3

  handlers:
    - name: Restart app
      ansible.builtin.systemd:
        name: myapp
        state: restarted

The first batch — a single canary host — deploys and verifies. If that batch fails, the whole play stops right there by default, before the remaining batch is ever touched — protecting every other host from a genuinely broken deployment. Only once the canary succeeds does execution proceed to the second batch, "100%" of whatever remains.

Best Practices

  • Use forks to match overall parallelism to control-node and target capacity — higher for many lightweight hosts, lower for heavier per-host operations.
  • Use serial whenever limiting blast radius genuinely matters — start with a small canary batch and increase gradually rather than deploying to everything at once.
  • Use throttle for one specific task hitting a shared or rate-limited resource, without throttling the entire play to match.
  • Use order: shuffle to avoid systematic bias from always processing hosts in the same fixed sequence, and sorted/reverse_sorted when a predictable, human-readable sequence matters more.
  • Combine a small first serial batch with a health-check task for genuine canary-deployment safety, rather than trusting a deployment succeeded just because no task reported an error.
Last updated on