Skip to main content
RunBook Academy

AnsibleXXXIV · Concurrency, Strategies and PerformanceConcurrency, strategies and performance

throttle: protecting shared dependencies

Intermediate⏱ ~22 minansible-core

What you'll learn

  • Use throttle to bound concurrency for the tasks that touch a shared dependency
  • State the relationship between throttle, forks and serial, and demonstrate it
  • Distinguish throttle from serial and from run_once by what each one controls
  • Apply throttle at block level so a whole critical section is serialised

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

The previous lesson named three ceilings on a run: the controller, the rate at which the fleet accepts connections, and the shared things every host depends on. forks is the lever for the first. throttle is the lever for the third, and it is the only one of the concurrency keywords that is about a machine which is not in your play.

The shape it addresses:

Two hundred hosts run a package task. All two hundred fetch from one internal mirror. The mirror serves five concurrent clients comfortably, forty badly, and two hundred not at all — and the errors come back as timeouts that look like a network problem.

Lowering forks would fix it and would slow down every other task in the run. throttle lowers concurrency for the tasks that need it and leaves the rest alone.

What it does, measured

Four hosts, one task that takes two seconds each, run four ways:

Read-only / Safethrottle bounds concurrency and cannot raise it
$ /usr/bin/time -f 'elapsed %e s' ansible-playbook -i thr.ini thr.yml -f <N> [-e thr=<N>]
-- forks 5, no throttle --
elapsed 2.48 s

-- forks 5, throttle 1 --
elapsed 8.91 s

-- forks 1, no throttle --
elapsed 8.92 s

-- forks 1, throttle 4 (cannot raise above forks) --
elapsed 8.91 s

Four hosts times two seconds is eight seconds of work. Done in parallel it takes 2.5 seconds; serialised it takes 8.9. throttle: 1 at forks: 5 produced the serialised time, which is the keyword working.

The fourth measurement is the one worth keeping. throttle: 4 with forks: 1 still took 8.9 seconds — asking for more concurrency than forks allows achieves nothing. The keyword documentation states it directly:

Read-only / Safethe definition on your own controller
$ ansible-doc -t keyword throttle
throttle:
applies_to:
- Play
- Role
- Block
- Task
- Handler
description: Limit the number of concurrent task runs on task, block and playbook
  level. This is independent of the forks and serial settings, but cannot be set
  higher than those limits. For example, if forks is set to 10 and the throttle
  is set to 15, at most 10 hosts will be operated on in parallel.
priority: 0
template: explicit
type: int

throttle only ever lowers concurrency. It is a floor-ward adjustment, never a ceiling-ward one, which makes it safe to add: the worst case of a wrong value is a run that is slower than it needed to be.

Where to put it

The keyword applies to Play, Role, Block, Task and Handler, and the level you choose changes what is protected.

On the task

The narrow, common case. One task touches the shared thing; everything else in the play runs at full concurrency.

Configuration changeone task, one mirror
- name: Install the agent from the internal mirror
ansible.builtin.package:
  name: monitoring-agent
  state: present
throttle: 5

On a block

The case people miss. If a sequence of tasks holds the shared resource, throttling one of them leaves the others racing.

Service impact possiblea whole critical section, serialised
- name: Licensed reconfiguration
block:
  - name: Check out a licence seat
    ansible.builtin.uri:
      url: "https://licence.example.com/api/checkout"
      method: POST
    register: seat

  - name: Apply the licensed configuration
    ansible.builtin.command: /opt/vendor/bin/reconfigure --seat {{ seat.json.id }}

  - name: Return the seat
    ansible.builtin.uri:
      url: "https://licence.example.com/api/release/{{ seat.json.id }}"
      method: POST
throttle: 1

With throttle: 1 on the individual checkout task instead, host A would check out a seat, and while it was running reconfigure, host B would be free to check out the next one. The pool would empty exactly as fast as it would have without the keyword.

On a handler

Handlers inherit nothing from the tasks that notified them, so a handler that restarts a service which registers with a shared discovery system needs its own throttle:

handlers:
  - name: restart app
    ansible.builtin.systemd:
      name: app
      state: restarted
    throttle: 4

On a play or role

Blunt but occasionally right — a role that exists entirely to talk to one API, for example. Prefer the narrowest level that covers the resource, because a play-level throttle slows down every task in the play including the ones that touch nothing shared.

throttle, serial and run_once are three different questions

They get confused constantly, and the confusion produces real defects.

KeywordQuestion it answersEffect on a 400-host play
forksHow much can the controller do at once?Ceiling on workers, fleet-wide
serialHow many hosts may be mid-change at once, and where do we stop?Batches; failure policy is evaluated at batch boundaries
throttleHow many hosts may run this task at once?Lowers concurrency for the throttled scope only
run_onceShould one host do this on behalf of the batch?One execution per batch; see the delegation part

The two substitutions that go wrong:

run_once where throttle: 1 was meant. Each host needs to register itself with a catalogue, and the catalogue tolerates one writer. run_once registers one host and leaves 399 unregistered — a correctness bug, and a quiet one, because the task reports ok.

throttle: 1 where serial: 1 was meant. The intent was “change one host at a time, and stop if one fails”. throttle: 1 gives the first half and not the second: with the default failure handling, other hosts continue after one fails, and there is no batch boundary at which max_fail_percentage is evaluated.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A play runs with forks: 1 and a task carrying throttle: 4. Four hosts each take two seconds on that task. How long does the task take?

  2. Q2. A block checks out a licence seat, runs a reconfiguration with it, then releases the seat. The licence pool has one seat. Where should throttle: 1 go?

  3. Q3. Which statements correctly distinguish throttle from serial and run_once? Select all that apply.

  4. Q4. Adding throttle: 1 to every task in a play is a reasonable substitute for serial: 1 when you want to change one host at a time and stop on the first failure.

Passing score: 75%. Answers are checked in this browser.