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.
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
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?
The fast ramp starts the three users together instead of spacing their starts over three seconds.
What does a lower Constant Timer change most directly?
Intentional pre-sampler think/pacing delay; in a closed workload it can increase iteration/sample throughput without changing target service time.
Why can Experiment 4 produce far fewer than 200 samples?
The 4-second scheduler lifetime ends the group before 2 × 100 loops finish; loops and duration race, whichever terminates first.
Are JTL grpThreads values a continuous active-user monitor?
No. They are thread-count fields recorded with samples; they show observed thread state at sample events.
Why is the Open Model comparison optional?
The current built-in component is still experimental and may change, so stable mandatory learning uses classic Thread Group semantics and a built-in throughput-timer fallback.
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-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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.