Skip to main content

What is Concurrency Testing in Software Testing?

What is Concurrency Testing in Software Testing?

Key takeaways

  • Concurrency testing checks that your software stays correct when operations overlap in time. It looks for race conditions, deadlocks, lost updates and double processing. It is a bug-finding technique, not a capacity measurement.
  • Load testing asks how fast and how stable a system is under many users. Concurrency testing asks whether the answers are still right. A system can pass one and fail the other.
  • The bugs need shared state: a balance, a stock count, a single-use coupon, a cache entry. A stateless read endpoint rarely has anything to race on.
  • Failures are intermittent, so run the same test many times and with real parallelism. A single green run proves very little.

Concurrency testing is the practice of running operations at the same time to find defects that only appear when they overlap: two requests that both pass a check before either one updates the state, two threads that wait on each other forever, a counter that loses increments. The phrase also gets used for something different, which is loading a system with many simultaneous users. This page is mainly about the first meaning, the bug-finding one, and it points to the right guides for the second.

Two meanings of “concurrency testing”

Concurrency testing (correctness)Load testing with concurrent users
QuestionDoes it still give correct results when operations overlap?How fast and stable is it with N users at once?
Typical defectsRace condition, deadlock, lost update, double chargeSlow responses, errors under load, saturated CPU or pool
Load neededOften small (2 to 50 parallel callers), but timed to collideAs much as the scenario calls for
Pass conditionInvariants hold (balance never negative, coupon used once)Response time and error rate stay within targets
Where to read moreThis pageCalculate concurrent users, virtual users

Search data for this URL was too thin to tell which sense visitors expect: Search Console shows no impressions for the page in the last 90 days. The searches that do reach loadfocus.com for “concurrency testing” land on load testing templates, so both senses matter, and the rest of this guide keeps them apart.

What concurrency bugs look like

Race condition. The result depends on the order in which threads or requests happen to run. The classic form is check-then-act: read a value, decide, then write, with a gap in between that another caller can use.

Lost update. Two callers read the same value, both add to it, and the second write overwrites the first.

Deadlock. Two callers each hold a lock the other needs and both wait forever. Databases usually detect and abort one transaction; application locks usually do not.

Double processing. The same event is handled twice, for example a payment retry that charges twice, or a message consumed by two workers.

None of these show up in a test that sends one request at a time, and most do not show up reliably in a test that merely sends many requests in a stream.

A race condition you can run

This Python program models a bank account with 100 in it and starts 10 threads that each try to withdraw 100. Only one withdrawal can succeed. The unsafe version checks the balance, then updates it, with a short pause between the two steps to stand in for any real delay such as a database call:

import threading, time

class Account:
    def __init__(self, balance):
        self.balance = balance
        self.lock = threading.Lock()

    def withdraw_unsafe(self, amount):
        if self.balance >= amount:        # check
            time.sleep(0.001)             # the gap another thread can use
            self.balance -= amount        # act
            return True
        return False

    def withdraw_safe(self, amount):
        with self.lock:                   # check and act as one step
            if self.balance >= amount:
                self.balance -= amount
                return True
            return False

I ran it with Python 3.10.11, three times for each version. Output:

withdraw_unsafe: 10 of 10 withdrawals of 100 succeeded, final balance -900
withdraw_unsafe: 10 of 10 withdrawals of 100 succeeded, final balance -900
withdraw_unsafe: 10 of 10 withdrawals of 100 succeeded, final balance -900
withdraw_safe: 1 of 10 withdrawals of 100 succeeded, final balance 0
withdraw_safe: 1 of 10 withdrawals of 100 succeeded, final balance 0
withdraw_safe: 1 of 10 withdrawals of 100 succeeded, final balance 0

All ten threads passed the check before any of them reached the update, so the account ended at -900. The fix is to make check and update one atomic step with a lock. The sleep is there to make the race appear on every run; without it the gap is microseconds wide and the bug shows up rarely, which is exactly what makes real races hard to catch. Python’s own thread switching, the interpreter lock and your hardware will change how often it fires, so treat the numbers as an illustration, not a benchmark.

A deadlock you can run

Two threads take two locks in opposite order:

import threading, time
a, b = threading.Lock(), threading.Lock()

def t1():
    with a:
        time.sleep(0.1)
        if not b.acquire(timeout=1): print("t1: gave up waiting for b (deadlock)")
        else: b.release()

def t2():
    with b:
        time.sleep(0.1)
        if not a.acquire(timeout=1): print("t2: gave up waiting for a (deadlock)")
        else: a.release()

x = [threading.Thread(target=t1), threading.Thread(target=t2)]
[t.start() for t in x]; [t.join() for t in x]

Output from the same machine:

t2: gave up waiting for a (deadlock)
t1: gave up waiting for b (deadlock)

I added the one-second timeout so the script ends; without it both threads wait forever. The standard fix is to take locks in the same order everywhere (always a before b), or to avoid holding one lock while asking for another. Timeouts on lock acquisition, as shown, turn a silent hang into a logged failure.

The same bug through HTTP

Most teams meet this bug as a web or API defect: a single-use coupon redeemed twice, the last item in stock sold twice, a double-clicked “pay” button. I wrote a small Python server whose /redeem/<code> endpoint checks a set of used codes, waits 10 ms for a pretend database write, then records the code. Twenty simultaneous POSTs for one coupon gave this:

unsafe: 20 simultaneous requests for one single-use coupon -> 7 redeemed, 13 refused
safe  : 20 simultaneous requests for one single-use coupon -> 1 redeemed, 19 refused

A second run gave 5 redeemed for the unsafe version and again 1 for the locked one, so the count of duplicates changes from run to run and the correct answer does not. In production the equivalent of the lock is usually a database constraint (a unique index on the redemption), an atomic conditional update, or an idempotency key sent by the client, not an in-process lock, because several servers run the same code.

To aim a tool at this kind of defect, the request has to arrive together. In JMeter the Synchronizing Timer blocks threads until a set number are waiting and then releases them all at once, which is how you fire the 20 coupon requests in the same instant. The manual warns that it blocks only within one JVM, and that with a timeout of 0 and too few threads the test pauses forever, so set a timeout. The ramp-up explanation in the ramp-up guide covers the opposite situation, spreading starts out.

How to find concurrency bugs

  • Make the collision likely. Release many callers at the same instant (a barrier, a synchronizing timer, a thread pool started together) rather than ramping up slowly.
  • Repeat. Run the test hundreds of times. One pass that finds nothing is not evidence.
  • Assert invariants, not outputs. Check “balance is never negative”, “this coupon has one redemption row”, “stock equals starting stock minus orders”. Do not compare response times.
  • Use a race detector where one exists. Go’s -race flag (go test -race ./...) reports data races with stack traces, but its documentation says it only finds races that happen at run time, so a clean run says nothing about code paths your tests did not execute. Run a race-enabled binary under a realistic workload as well.
  • Check the database, not only the response. Duplicates often hide behind two “200 OK” answers.
  • Read the code for check-then-act. An if followed by a write on shared state is the pattern to search for.

Where load tests fit

A load test with many virtual users can expose concurrency bugs as a side effect: duplicated rows, deadlocked transactions and a rising error rate at high concurrency are all reported by its error counts. That makes load tests useful for this work, but they are not designed for it, because the aim of a load test is realistic traffic spread out over time, and a race needs a collision at one instant on one piece of data. If you want the concurrent-user side, start with how many simultaneous users you have to design for in the concurrent users guide and the virtual users guide, then see the types of performance testing for where stress, spike and soak tests fit. They are load shapes, not parts of concurrency testing.

To run a parameterized JMeter script from several cloud regions, LoadFocus accepts standard .jmx files; see its JMeter load testing page for the current limits. Prepare the data the script sends, such as one coupon code per user, as described in the JMeter parameterization guide.

Frequently Asked Questions

What is concurrency testing in software testing?

It is testing how software behaves when operations overlap in time, with the goal of finding race conditions, deadlocks, lost updates and double processing. Success means the system’s rules still hold, for example that a single-use coupon is redeemed once, however the operations interleave.

What is the difference between concurrency testing and load testing?

Concurrency testing checks correctness under overlapping operations and can use few callers if they collide on the same data. Load testing measures response time, throughput and errors under a chosen number of users. The first finds wrong answers, the second finds slow or failing ones.

What is the difference between concurrent users and concurrency testing?

Concurrent users is a number: how many people are active at the same time. Concurrency testing is an activity that checks the system’s behavior when activity overlaps. You calculate concurrent users to design a load test, and you run concurrency tests to look for defects.

How do you test for race conditions?

Release many callers at the same moment against the same piece of shared state, repeat the run many times, and assert the invariant (one redemption, balance not negative) rather than the response. Use a race detector for languages that have one.

Why is concurrency testing hard to do?

The failures depend on timing, so they are intermittent and may not appear on a developer machine. Repetition, deliberate collisions and invariant checks are what make them reproducible.

Sources

Bogdan

Founder at LoadFocus

Bogdan builds and runs the tools this blog is about. He writes from what the products actually do in production, including the parts that break.

Weekly tips right in your inbox.

Don’t miss out on the latest testing tips and tricks, tools and news related to software testing.

Already +20,000 members in the list.

The Load Testing Platform with AI That Explains Your Results

Load test websites and APIs from 25 cloud regions, monitor page speed and uptime, and get AI analysis that explains your results in plain English.

Cloud load testing, website speed and API monitoring in one place.

Hi there! Can I share a cool thing I'm working on with you?

A software testing newsletter that sparks joy.

Our goal is to create helpful content for software developers and testers.

If you're not into it, you can unsubscribe instantly.