Chapter 04Lesson 02~175 minutes

Thread Groups, Virtual Users, Ramp-Up, Loops, and Workload Modeling: Guided Hands-On Workflow

Workload modeling becomes credible when one factor changes at a time. These experiments keep the same loopback service and HTTP sampler while varying classic threads, ramp-up, loop count, duration, and timer delay under conservative ceilings.

ExperimentsActive threadsThroughputPercentilesGenerator health

Learning objectives

  • Run a reproducible loopback fixture that exposes target-side active/max concurrency.
  • Execute classic Thread Group profiles in CLI mode with explicit JTL thread-count fields.
  • Compare one-thread, gradual-ramp, fast-ramp, duration-bound, and timer-change experiments.
  • Compute achieved throughput, p50/p95 elapsed time, errors, and observed JMeter thread counts.
  • Separate target concurrency evidence from generator resource observations.
  • Optionally inspect Open Model syntax without making it a mandatory stable dependency.

1. Safety envelope and run discipline

Maximum mandatory load: target http://127.0.0.1:8000; at most 3 threads in the one-factor experiments; at most 4 seconds of scheduler-bound execution; response delay capped by the fixture at 500 ms; no external targets. Restart the fixture between evidence runs. Abort on target mismatch, unexpected errors, or unsafe generator pressure.

Use GUI mode only to edit/inspect Thread Group fields. Save each JMX, disable heavy GUI listeners, then run the evidence copy from CLI.

2. Start a fixture that exposes active concurrency

Save as fixtures/workload_fixture.py:

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
import json
import threading
import time

HOST = "127.0.0.1"
PORT = 8000

lock = threading.Lock()
active = 0
max_active = 0
total = 0

class Handler(BaseHTTPRequestHandler):
    def _json(self, status, payload):
        body = json.dumps(payload, sort_keys=True).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        global active, max_active, total
        parsed = urlparse(self.path)

        if parsed.path == "/health":
            self._json(200, {"status": "ok"})
            return

        if parsed.path == "/stats":
            with lock:
                snapshot = {
                    "active": active,
                    "max_active": max_active,
                    "total": total,
                }
            self._json(200, snapshot)
            return

        if parsed.path != "/work":
            self._json(404, {"error": "not_found"})
            return

        values = parse_qs(parsed.query)
        try:
            delay_ms = int(values.get("delay_ms", ["100"])[0])
        except ValueError:
            delay_ms = 100
        delay_ms = min(max(delay_ms, 0), 500)

        with lock:
            active += 1
            total += 1
            max_active = max(max_active, active)
            active_at_start = active
            request_id = total

        try:
            time.sleep(delay_ms / 1000.0)
            self._json(
                200,
                {
                    "status": "ok",
                    "request_id": request_id,
                    "delay_ms": delay_ms,
                    "active_at_start": active_at_start,
                },
            )
        finally:
            with lock:
                active -= 1

    def log_message(self, format, *args):
        return

if __name__ == "__main__":
    print(f"fixture=http://{HOST}:{PORT}")
    ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()

Start and preflight:

python fixtures/workload_fixture.py
# another terminal
curl --fail --silent http://127.0.0.1:8000/health
curl --fail --silent http://127.0.0.1:8000/stats

PowerShell:

(Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8000/health).Content
(Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8000/stats).Content

3. Baseline JMX: one user, five loops

This plan has one HTTP sampler, a 300 ms Constant Timer, and a 100 ms synthetic target delay. Save it as plans/01-one-user.jmx or reproduce it in the GUI:

<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.3">
  <hashTree>
    <TestPlan guiclass="TestPlanGui" testclass="TestPlan"
              testname="Chapter 04 Classic Workload" enabled="true">
      <boolProp name="TestPlan.functional_mode">false</boolProp>
      <boolProp name="TestPlan.tearDown_on_shutdown">true</boolProp>
      <boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
      <elementProp name="TestPlan.user_defined_variables"
                   elementType="Arguments"
                   guiclass="ArgumentsPanel"
                   testclass="Arguments"
                   testname="User Defined Variables">
        <collectionProp name="Arguments.arguments"/>
      </elementProp>
      <stringProp name="TestPlan.user_define_classpath"></stringProp>
    </TestPlan>
    <hashTree>
      <ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup"
                   testname="Classic — 1 user × 5 loops" enabled="true">
        <stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
        <elementProp name="ThreadGroup.main_controller"
                     elementType="LoopController"
                     guiclass="LoopControlPanel"
                     testclass="LoopController"
                     testname="Loop Controller">
          <boolProp name="LoopController.continue_forever">false</boolProp>
          <stringProp name="LoopController.loops">5</stringProp>
        </elementProp>
        <stringProp name="ThreadGroup.num_threads">1</stringProp>
        <stringProp name="ThreadGroup.ramp_time">1</stringProp>
        <boolProp name="ThreadGroup.scheduler">false</boolProp>
        <stringProp name="ThreadGroup.duration"></stringProp>
        <stringProp name="ThreadGroup.delay"></stringProp>
        <boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp>
      </ThreadGroup>
      <hashTree>
        <ConstantTimer guiclass="ConstantTimerGui" testclass="ConstantTimer"
                       testname="Think time — 300 ms" enabled="true">
          <stringProp name="ConstantTimer.delay">300</stringProp>
        </ConstantTimer>
        <hashTree/>
        <HTTPSamplerProxy guiclass="HttpTestSampleGui"
                          testclass="HTTPSamplerProxy"
                          testname="Work" enabled="true">
          <elementProp name="HTTPsampler.Arguments"
                       elementType="Arguments"
                       guiclass="HTTPArgumentsPanel"
                       testclass="Arguments"
                       testname="User Defined Variables">
            <collectionProp name="Arguments.arguments"/>
          </elementProp>
          <stringProp name="HTTPSampler.domain">127.0.0.1</stringProp>
          <stringProp name="HTTPSampler.port">8000</stringProp>
          <stringProp name="HTTPSampler.protocol">http</stringProp>
          <stringProp name="HTTPSampler.path">/work?delay_ms=100</stringProp>
          <stringProp name="HTTPSampler.method">GET</stringProp>
          <boolProp name="HTTPSampler.follow_redirects">true</boolProp>
          <boolProp name="HTTPSampler.use_keepalive">true</boolProp>
          <stringProp name="HTTPSampler.connect_timeout">1000</stringProp>
          <stringProp name="HTTPSampler.response_timeout">2000</stringProp>
        </HTTPSamplerProxy>
        <hashTree/>
      </hashTree>
    </hashTree>
  </hashTree>
</jmeterTestPlan>

The configured maximum is five samples. Because the timer runs before each sampler, one iteration takes roughly the timer plus response time and local overhead. Exact elapsed response time should remain near the target delay rather than including the whole timer.

4. Use one explicit CLI result contract

Every experiment uses unique output directories and explicitly preserves CSV headers, thread counts, and default end-of-sample timestamp semantics:

mkdir -p results/01-one-user
jmeter -n   -t plans/01-one-user.jmx   -l results/01-one-user/results.jtl   -j results/01-one-user/jmeter.log   -Jjmeter.save.saveservice.print_field_names=true   -Jjmeter.save.saveservice.thread_counts=true   -Jsampleresult.timestamp.start=false

PowerShell:

New-Item -ItemType Directory -Force results\01-one-user | Out-Null
jmeter.bat -n `
  -t plans\01-one-user.jmx `
  -l results\01-one-user\results.jtl `
  -j results\01-one-user\jmeter.log `
  -Jjmeter.save.saveservice.print_field_names=true `
  -Jjmeter.save.saveservice.thread_counts=true `
  -Jsampleresult.timestamp.start=false

5. Analyze achieved rate and active-thread evidence

Save as tools/analyze_jtl.py:

import csv
import math
from pathlib import Path
import sys

path = Path(sys.argv[1] if len(sys.argv) > 1 else "results/run-001/results.jtl")
rows = list(csv.DictReader(path.open(encoding="utf-8")))

required = {"timeStamp", "elapsed", "label", "success", "grpThreads", "allThreads"}
missing = required.difference(rows[0].keys() if rows else set())
if missing:
    raise SystemExit(f"Missing JTL columns: {sorted(missing)}")

def pct(values, p):
    values = sorted(values)
    rank = max(1, math.ceil((p / 100) * len(values)))
    return values[rank - 1]

elapsed = [int(r["elapsed"]) for r in rows]
success = [r["success"].strip().lower() == "true" for r in rows]
# Chapter 04 explicitly keeps JMeter's default timestamp-at-end behavior.
starts = [int(r["timeStamp"]) - int(r["elapsed"]) for r in rows]
ends = [int(r["timeStamp"]) for r in rows]
span_s = max((max(ends) - min(starts)) / 1000.0, 0.001) if rows else 0.0

print(f"samples={len(rows)}")
print(f"errors={sum(not ok for ok in success)}")
print(f"error_rate_pct={100 * sum(not ok for ok in success) / len(rows):.2f}" if rows else "error_rate_pct=NA")
print(f"p50_elapsed_ms={pct(elapsed, 50)}" if rows else "p50_elapsed_ms=NA")
print(f"p95_elapsed_ms={pct(elapsed, 95)}" if rows else "p95_elapsed_ms=NA")
print(f"achieved_samples_per_second={len(rows) / span_s:.2f}" if rows else "achieved_samples_per_second=NA")
print(f"max_grpThreads_seen={max(int(r['grpThreads']) for r in rows)}" if rows else "max_grpThreads_seen=NA")
print(f"max_allThreads_seen={max(int(r['allThreads']) for r in rows)}" if rows else "max_allThreads_seen=NA")
print("thread_series:")
for r in rows:
    print(f"  ts={r['timeStamp']} label={r['label']} grpThreads={r['grpThreads']} allThreads={r['allThreads']} elapsed={r['elapsed']}")

grpThreads and allThreads are sampled fields attached to sample results; they are not a continuous profiler. The fixture's max_active independently describes server-side concurrent requests. Both are useful, but they measure different states.

6. Experiment 1 — one user, five loops

Prediction: 5 samples, zero errors, JMeter thread counts near 1 while work is active, fixture max_active=1, and achieved throughput constrained by roughly 300 ms think time + 100 ms response time per closed user iteration.

Run the baseline, analyze its JTL, record Task Manager/top generator CPU/memory, and query fixture stats. Treat the exact rate as observed evidence, not a constant.

7. Experiment 2 — increase users, keep everything else

Copy the plan to 02-three-users-gradual.jmx. Change only:

  • Threads: 3
  • Ramp-up: 3 seconds
  • Loops: 3

Leave the 300 ms timer and 100 ms target delay unchanged. Configured maximum = 9 samples.

Prediction: total throughput rises because several user loops can overlap, but the gradual ramp means all 3 users are not active immediately. Fixture max_active may exceed 1 when request windows overlap. JTL grpThreads should show the population growing/declining around sample events.

8. Experiment 3 — fast ramp, same threads and loops

Copy Experiment 2 to 03-three-users-fast.jmx and change only Ramp-up from 3 seconds to 0.

Prediction: three threads start together, so the first request burst is more synchronized and fixture max_active is more likely to reach 3. Total configured samples remain 9. If elapsed times rise materially, correlate with target and generator state before calling it a server regression.

9. Experiment 4 — duration becomes the primary bound

Copy the baseline to 04-duration-bound.jmx and change:

  • Threads: 2
  • Ramp-up: 1 second
  • Loop Count: 100 (a high ceiling, not intended to finish)
  • Specify Thread lifetime / scheduler: enabled
  • Duration: 4 seconds
  • Constant Timer: 500 ms

Prediction: the duration boundary should stop the group before 100 loops per thread complete. Because the boundary is checked between samples, final wall time can extend beyond exactly four seconds if a sampler is already waiting. Sample count must be measured, not calculated as “2 × 100.”

10. Experiment 5 — change think time only

Start from Experiment 2 and create 05-faster-think.jmx. Keep 3 threads, 3-second ramp, 3 loops, and target delay 100 ms; change Constant Timer only from 300 ms to 100 ms.

Prediction: configured sample count stays 9, sampler elapsed distribution should remain similar, while achieved throughput/iteration completion tends to increase because each closed user waits less between requests.

11. Record a run manifest beside each JTL

run_id=03-three-users-fast
jmeter=5.6.3
java=17
target=http://127.0.0.1:8000
threads=3
ramp_up_seconds=0
loops=3
timer_ms=300
target_delay_ms=100
expected_max_samples=9
generator_observation=record CPU/memory manually
abort=target mismatch, unexpected errors, unsafe resource pressure

A manifest prevents a comparison from collapsing into two filenames with no explanation of the configured workload.

12. Optional comparison — current experimental Open Model

Only after the stable experiments are understood, create a separate optional plan with one sampler scenario and current Open Model syntax such as:

rate(1/sec) random_arrivals(6 sec) pause(2 sec)

The current component is experimental. With one sampler per scenario, this schedule makes scenario arrivals easy to compare with sample arrivals. With multiple samplers, do not call the schedule “request rate.” The ending pause(2 sec) gives already-started scenarios some time to finish because current Open Model behavior can terminate threads when the schedule ends.

Keep this plan out of mandatory gates unless your team explicitly accepts and pins the experimental semantics.

13. Challenge: concurrency or rate?

A support desk has exactly three agents, each completes a workflow and then waits before the next customer. A webhook endpoint, by contrast, receives external events independently of whether earlier events are still processing. Which model fits each?

Choose a classic fixed-user model for the bounded agent population; use open-arrival reasoning for the webhook. If you need a stable rate-oriented local JMeter fallback without experimental Open Model, use a bounded classic pool plus a throughput timer and measure achieved rate.

Knowledge check

Why can Experiment 3 produce a higher startup concurrency than Experiment 2 with the same threads and loops?

What does a lower Constant Timer change most directly?

Why can Experiment 4 produce far fewer than 200 samples?

Are JTL grpThreads values a continuous active-user monitor?

Why is the Open Model comparison optional?

Next lesson

Choose a schedule because it matches demand

Lesson 3 compares fixed threads with rate-oriented/open designs, iterations with duration, ramp shapes, multiple workload classes, repeatable versus randomized schedules, and stable versus experimental components.

Official references and version notes

  • Component Reference — current classic Thread Group, Open Model Thread Group, Precise Throughput Timer, and timer semantics.
  • Elements of a Test Plan — sampler ordering, Thread Groups, timer purpose/scope, and listener guidance.
  • Properties Reference — CSV result-save settings including thread-count fields and timestamp behavior.
  • Getting Started — GUI authoring versus CLI load execution and command-line behavior.
  • Best Practices — generator/listener practices for load execution.
  • Apache JMeter downloads — current production release and release-specific Java requirement.
Version and compatibility note

Version-sensitive statements were rechecked against current Apache JMeter primary documentation on 2026-09-04. The production baseline remains Apache JMeter 5.6.3; the release requires Java 8+, while mandatory labs use a Java 17 JDK. The classic Thread Group controls user/thread count, ramp-up, loops, and optional lifetime; with its scheduler, JMeter stops when loops finish or duration/end-time is reached, whichever occurs first, but the check happens between samples and an in-flight sampler waiting for a response is not forcibly ended by that scheduler boundary. The current Open Model Thread Group is explicitly marked experimental and may change; its schedule is evaluated at test start and its schedule end terminates/intercepts active scenario threads unless a tail pause(...) leaves time to finish. The stable mandatory rate-oriented path in this chapter uses the classic Thread Group plus the built-in Precise Throughput Timer. That timer does not create threads and cannot guarantee achieved throughput when too few threads, generator limits, target limits, or other delays prevent the schedule from being served.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.