压力测试和模型检测

Lincheck offers two testing strategies: stress testing and model checking. Learn what happens under the hood of both approaches using the Counter we coded in the BasicCounterTest.kt file in the previous step:

  1. class Counter {
  2. @Volatile
  3. private var value = 0
  4. fun inc(): Int = ++value
  5. fun get() = value
  6. }

Stress testing

Write a stress test

Create a concurrent stress test for the Counter, following these steps:

  1. Create the CounterTest class.
  2. In this class, add the field c of the Counter type, creating an instance in the constructor.
  3. List the counter operations and mark them with the @Operation annotation, delegating their implementations to c.
  4. Specify the stress testing strategy using StressOptions().
  5. Invoke the StressOptions.check() function to run the test.

The resulting code will look like this:

  1. import org.jetbrains.kotlinx.lincheck.annotations.*
  2. import org.jetbrains.kotlinx.lincheck.check
  3. import org.jetbrains.kotlinx.lincheck.strategy.stress.*
  4. import org.junit.*
  5. class CounterTest {
  6. private val c = Counter() // Initial state
  7. // Operations on the Counter
  8. @Operation
  9. fun inc() = c.inc()
  10. @Operation
  11. fun get() = c.get()
  12. @Test // Run the test
  13. fun stressTest() = StressOptions().check(this::class)
  14. }

How stress testing works

At first, Lincheck generates a set of concurrent scenarios using the operations marked with @Operation. Then it launches native threads, synchronizing them at the beginning to guarantee that operations start simultaneously. Finally, Lincheck executes each scenario on these native threads multiple times, expecting to hit an interleaving that produces incorrect results.

The figure below shows a high-level scheme of how Lincheck may execute generated scenarios:

Stress execution of the Counter

Model checking

The main concern regarding stress testing is that you may spend hours trying to understand how to reproduce the found bug. To help you with that, Lincheck supports bounded model checking, which automatically provides an interleaving for reproducing bugs.

A model checking test is constructed the same way as the stress test. Just replace the StressOptions() that specify the testing strategy with ModelCheckingOptions().

Write a model checking test

To change the stress testing strategy to model checking, replace StressOptions() with ModelCheckingOptions() in your test:

  1. import org.jetbrains.kotlinx.lincheck.annotations.*
  2. import org.jetbrains.kotlinx.lincheck.check
  3. import org.jetbrains.kotlinx.lincheck.strategy.managed.modelchecking.*
  4. import org.junit.*
  5. class CounterTest {
  6. private val c = Counter() // Initial state
  7. // Operations on the Counter
  8. @Operation
  9. fun inc() = c.inc()
  10. @Operation
  11. fun get() = c.get()
  12. @Test // Run the test
  13. fun modelCheckingTest() = ModelCheckingOptions().check(this::class)
  14. }

To use model checking strategy for Java 9 and later, add the following JVM properties:

  1. --add-opens java.base/jdk.internal.misc=ALL-UNNAMED
  2. --add-exports java.base/jdk.internal.util=ALL-UNNAMED

They are required if the testing code uses classes from the java.util package since some of them use jdk.internal.misc.Unsafe or similar internal classes under the hood. If you use Gradle, add the following lines to build.gradle.kts:

  1. tasks.withType<Test> {
  2. jvmArgs(
  3. "--add-opens", "java.base/jdk.internal.misc=ALL-UNNAMED",
  4. "--add-exports", "java.base/jdk.internal.util=ALL-UNNAMED",
  5. "--add-exports", "java.base/sun.security.action=ALL-UNNAMED"
  6. )
  7. }

测试策略 - 图2

How model checking works

Most bugs in complicated concurrent algorithms can be reproduced with classic interleavings, switching the execution from one thread to another. Besides, model checkers for weak memory models are very complicated, so Lincheck uses a bounded model checking under the sequential consistency memory model.

In short, Lincheck analyzes all interleavings, starting with one context switch, then two, continuing the process until the specified number of interleaving is examined. This strategy allows finding an incorrect schedule with the lowest possible number of context switches, making further bug investigation easier.

To control the execution, Lincheck inserts special switch points into the testing code. These points identify where a context switch can be performed. Essentially, these are shared memory accesses, such as field and array element reads or updates in the JVM, as well as wait/notify and park/unpark calls. To insert a switch point, Lincheck transforms the testing code on the fly using the ASM framework, adding internal function invocations to the existing code.

As the model checking strategy controls the execution, Lincheck can provide the trace that leads to the invalid interleaving, which is extremely helpful in practice. You can see the example of trace for the incorrect execution of the Counter in the Write your first test with Lincheck tutorial.

Which testing strategy is better?

The model checking strategy is preferable for finding bugs under the sequentially consistent memory model since it ensures better coverage and provides a failing execution trace if an error is found.

Although stress testing doesn’t guarantee any coverage, checking algorithms for bugs introduced by low-level effects, such as a missed volatile modifier, is still helpful. Stress testing is also a great help in discovering rare bugs that require many context switches to reproduce, and it’s impossible to analyze them all due to the current restrictions in the model checking strategy.

Configure the testing strategy

To configure the testing strategy, set options in the <TestingMode>Options class.

  1. Set the options for scenario generation and execution for the CounterTest:

    1. import org.jetbrains.kotlinx.lincheck.annotations.*
    2. import org.jetbrains.kotlinx.lincheck.check
    3. import org.jetbrains.kotlinx.lincheck.strategy.stress.*
    4. import org.junit.*
    5. class CounterTest {
    6. private val c = Counter()
    7. @Operation
    8. fun inc() = c.inc()
    9. @Operation
    10. fun get() = c.get()
    11. @Test
    12. fun stressTest() = StressOptions() // Stress testing options:
    13. .actorsBefore(2) // Number of operations before the parallel part
    14. .threads(2) // Number of threads in the parallel part
    15. .actorsPerThread(2) // Number of operations in each thread of the parallel part
    16. .actorsAfter(1) // Number of operations after the parallel part
    17. .iterations(100) // Generate 100 random concurrent scenarios
    18. .invocationsPerIteration(1000) // Run each generated scenario 1000 times
    19. .check(this::class) // Run the test
    20. }
  2. Run stressTest() again, Lincheck will generate scenarios similar to the one below:

    1. | ------------------- |
    2. | Thread 1 | Thread 2 |
    3. | ------------------- |
    4. | inc() | |
    5. | inc() | |
    6. | ------------------- |
    7. | get() | inc() |
    8. | inc() | get() |
    9. | ------------------- |
    10. | inc() | |
    11. | ------------------- |

    Here, there are two operations before the parallel part, two threads for each of the two operations, followed after that by a single operation in the end.

You can configure your model checking tests in the same way.

Scenario minimization

You may already have noticed that detected errors are usually represented with a scenario smaller than the specified in the test configuration. Lincheck tries to minimize the error, actively removing an operation while it’s possible to keep the test from failing.

Here’s the minimized scenario for the counter test above:

  1. = Invalid execution results =
  2. | ------------------- |
  3. | Thread 1 | Thread 2 |
  4. | ------------------- |
  5. | inc() | inc() |
  6. | ------------------- |

As it’s easier to analyze smaller scenarios, scenario minimization is enabled by default. To disable this feature, add minimizeFailedScenario(false) to the [Stress, ModelChecking]Options configuration.

Logging data structure states

Another useful feature for debugging is state logging. When analyzing an interleaving that leads to an error, you usually draw the data structure changes on a sheet of paper, changing the state after each event. To automize this procedure, you can provide a special method that returns a String representation of the data structure, so Lincheck prints the state representation after each event in the interleaving that modifies the data structure.

For this, define a method that doesn’t take arguments and is marked with the @StateRepresentation annotation. The method should be thread-safe, non-blocking, and never modify the data structure.

  1. In the Counter example, the String representation is simply the value of the counter. Thus, to print the counter states in the trace, add the stateRepresentation() function to the CounterTest:

    1. import org.jetbrains.kotlinx.lincheck.annotations.*
    2. import org.jetbrains.kotlinx.lincheck.check
    3. import org.jetbrains.kotlinx.lincheck.strategy.managed.modelchecking.*
    4. import org.junit.Test
    5. class CounterTest {
    6. private val c = Counter()
    7. @Operation
    8. fun inc() = c.inc()
    9. @Operation
    10. fun get() = c.get()
    11. @StateRepresentation
    12. fun stateRepresentation() = c.get().toString()
    13. @Test
    14. fun modelCheckingTest() = ModelCheckingOptions().check(this::class)
    15. }
  2. Run the modelCheckingTest() now and check the states of the Counter printed at the switch points that modify the counter state (they start with STATE:):

    1. = Invalid execution results =
    2. | ------------------- |
    3. | Thread 1 | Thread 2 |
    4. | ------------------- |
    5. | STATE: 0 |
    6. | ------------------- |
    7. | inc(): 1 | inc(): 1 |
    8. | ------------------- |
    9. | STATE: 1 |
    10. | ------------------- |
    11. The following interleaving leads to the error:
    12. | -------------------------------------------------------------------- |
    13. | Thread 1 | Thread 2 |
    14. | -------------------------------------------------------------------- |
    15. | | inc() |
    16. | | inc(): 1 at CounterTest.inc(CounterTest.kt:10) |
    17. | | value.READ: 0 at Counter.inc(BasicCounterTest.kt:10) |
    18. | | switch |
    19. | inc(): 1 | |
    20. | STATE: 1 | |
    21. | | value.WRITE(1) at Counter.inc(BasicCounterTest.kt:10) |
    22. | | STATE: 1 |
    23. | | value.READ: 1 at Counter.inc(BasicCounterTest.kt:10) |
    24. | | result: 1 |
    25. | -------------------------------------------------------------------- |

In case of stress testing, Lincheck prints the state representation right before and after the parallel part of the scenario, as well as at the end.

测试策略 - 图3

Next step

Learn how to configure arguments passed to the operations and when it can be useful.