Тестирование многопоточного кода — сложная задача. Первый совет, который вы получите при попытке проверить параллелизм, — максимально изолировать ваши параллельные проблемы в коде. Это общий совет по дизайну, но в этом случае он еще важнее. Убедитесь, что сначала правильно проверен блок логики, который обернут параллельной конструкцией. В противном случае вы могли бы потратить много времени, пытаясь выяснить проблему параллелизма, которая в итоге оказывается ошибочной бизнес-логикой.
Как только вы это осветите, вы можете подумать о своей стратегии тестирования параллельных систем. ГСНО рассказывает, как вы можете это сделать. Здесь вы можете найти код, который я собираюсь объяснить:
Во-первых, давайте посмотрим на тестируемую систему:
01
02
03
04
05
06
07
08
09
10
11
|
public class AtomicBigCounter { private BigInteger count = BigInteger.ZERO; public BigInteger count() { return count; } public void inc() { count = count.add(BigInteger.ONE); } } |
Как видите, этот класс не является потокобезопасным, так как он предоставляет некоторое состояние с помощью метода inc (). Состояние не является потокобезопасным (вы можете использовать AtomicInteger вместо BigInteger, чтобы исправить это). Чтобы протестировать этот класс, мы включим непараллельный и параллельный тест.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
|
@Test public void canIncreaseCounter(){ ... } @Test public void canIncrementCounterFromMultipleThreadsSimultaneously() throws InterruptedException { MultithreadedStressTester stressTester = new MultithreadedStressTester( 25000 ); stressTester.stress( new Runnable() { public void run() { counter.inc(); } }); stressTester.shutdown(); assertThat( "final count" , counter.count(), equalTo(BigInteger.valueOf(stressTester.totalActionCount()))); } |
Стресс-тестер будет выполнять метод n петель с m нитями. Поскольку наш метод увеличивается на единицу, мы должны увидеть, что n*m
равно counter.count()
.
Интересным классом является MultithreadedStressTester, хотя:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
public void stress( final Runnable action) throws InterruptedException { spawnThreads(action).await(); } private CountDownLatch spawnThreads( final Runnable action) { final CountDownLatch finished = new CountDownLatch(threadCount); for ( int i = 0 ; i < threadCount; i++) { executor.execute( new Runnable() { public void run() { try { repeat(action); } finally { finished.countDown(); } } }); } return finished; } private void repeat(Runnable action) { for ( int i = 0 ; i < iterationCount; i++) { action.run(); } } |
Если вы выполните этот тест, вы получите другие результаты, а иногда даже проходящие! Это потому, что этот тест не является детерминированным, мы не можем гарантировать, как потоки будут чередоваться при каждом выполнении. Если мы хотим быть как можно более уверенными в том, что этот тест найдет возможную ошибку, мы должны увеличить количество потоков и итераций, но с очевидным временным компромиссом.
Вы можете использовать более детерминированный подход, используя Weaver . Чтобы понять, как это работает, давайте проиллюстрируем это на примере. Допустим, у нас есть хранилище в памяти и не поточно-ориентированное хранилище:
1
|
private final Map<Level, Scores> scoresByLevel; |
У нас есть некоторый сервис, который обращается к хранилищу, обертывающему этот магазин:
1
2
3
4
5
6
|
1 Optional<Scores> scoresFromStore = scoreRepo.findBy(score.level()); 2 if (scoresFromStore.isPresent()) { 3 scoreRepo.update(score.level(), score); 4 } else { 5 scoreRepo.save(score.level(), new Scores().add(score)); 6 } |
Этот сервис представляет собой одноэлементный сервер, который создает поток для каждого запроса, поэтому мы хотели бы выполнить этот фрагмент атомарно. Мы могли бы использовать недетерминированный подход к стресс-тесту или использовать Weaver. Если глубоко задуматься над этой проблемой, мы поймем, что хотим протестировать каждую комбинацию из следующих (например, поток 1 выполняет строку 1 в момент x, а поток 2 выполняет строку 1 в момент x, будет -> T1 / 1: Т2 / 1)
- T1 / 1: T2 / 1
- T1 / 1: T2 / 2
- T1 / 1: T2 / 3
- ….
- T1 / 2: T2 / 1
- T1 / 2: T2 / 2
- T1 / 2: T2 / 3
- ….
Например, у нас будет проблема, если T1 / 5 и T2 / 2, так как T1 еще не сохранил, а T2 уже получил пустой счет из магазина. Это означает, что T1 сохранит счет на уровне, а затем T2 сделает то же самое, нарушив логику. И это именно то, что делает Уивер, он захватывает метод и выполняет вышеуказанные комбинации, используя два потока.
Если я избавлюсь от подготовительного кода (помеченного @ThreadedBefore), тестовый код будет выглядеть так:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
|
@ThreadedMain public void mainThread() { scoreService.save(LEVEL_ID, SCORE_VALUE, aUser); } @ThreadedSecondary public void secondThread() { scoreService.save(LEVEL_ID, ANOTHER_SCORE_VALUE, aUser); } @ThreadedAfter public void after() { Optional<Scores> scores = scoreRepo.findBy(aLevel()); assertThat(scores.isPresent()).isTrue(); assertThat(scores.get().contains(aScoreWith(aUser))).isTrue(); assertThat(scores.get().contains(aDifferentScoreWith(aUser))).isTrue(); } @Test public void testThreading() { new AnnotatedTestRunner().runTests( this .getClass(), ScoreService. class ); } |
Этот тест всегда будет неудачным, поскольку он является детерминированным. Как вы можете видеть, тестирование параллелизма довольно сложно, и именно поэтому я сторонник современных сред, которые пытаются скрыть эти трудности в платформе или преодолеть проблему с помощью неизменных данных.
- Вы можете прочитать больше об этом здесь .
Ссылка: | Тестирование многопоточного кода на Java от нашего партнера по JCG Фелипе Фернандеса в блоге Crafted Software . |