자바에서 멀티 스레드 코드 테스트

1. 소개

이 튜토리얼에서는 동시 프로그램 테스트의 몇 가지 기본 사항을 다룰 것입니다. 우리는 주로 스레드 기반 동시성과 테스트에서 나타나는 문제에 중점을 둘 것입니다.

또한 이러한 문제 중 일부를 어떻게 해결하고 Java에서 다중 스레드 코드를 효과적으로 테스트 할 수 있는지 이해합니다.

2. 동시 프로그래밍

동시 프로그래밍은 큰 계산을 상대적으로 독립적 인 작은 계산으로 나누는 프로그래밍을 말합니다 .

이 연습의 목적은 이러한 작은 계산을 동시에, 가능하면 병렬로 실행하는 것입니다. 이를 달성하는 방법에는 여러 가지가 있지만 목표는 항상 프로그램을 더 빠르게 실행하는 것입니다.

2.1. 스레드 및 동시 프로그래밍

그 어느 때보 다 많은 코어를 처리하는 프로세서와 함께 동시 프로그래밍이이를 효율적으로 활용하는 최전선에 있습니다. 그러나 동시 프로그램은 설계, 작성, 테스트 및 유지 관리가 훨씬 더 어렵습니다 . 따라서 결국 동시 프로그램에 대한 효과적이고 자동화 된 테스트 케이스를 작성할 수 있다면 이러한 문제의 큰 덩어리를 해결할 수 있습니다.

그렇다면 동시 코드에 대한 테스트 작성을 그렇게 어렵게 만드는 것은 무엇입니까? 이를 이해하려면 프로그램에서 동시성을 달성하는 방법을 이해해야합니다. 가장 널리 사용되는 동시 프로그래밍 기술 중 하나는 스레드를 사용하는 것입니다.

이제 스레드는 기본이 될 수 있으며,이 경우 기본 운영 체제에 의해 예약됩니다. 런타임에 의해 직접 예약되는 녹색 스레드라고하는 것을 사용할 수도 있습니다.

2.2. 동시 프로그램 테스트의 어려움

우리가 사용하는 스레드 유형에 관계없이 스레드 통신을 사용하기가 어렵습니다. 스레드를 포함하지만 스레드 통신이없는 프로그램을 실제로 작성한다면 더 좋은 방법은 없습니다! 보다 현실적으로 스레드는 일반적으로 통신해야합니다. 이를 달성하는 방법에는 공유 메모리와 메시지 전달의 두 가지가 있습니다.

동시 프로그래밍과 관련된 대부분의 문제는 공유 메모리와 함께 원시 스레드를 사용하는 데서 발생합니다 . 같은 이유로 이러한 프로그램을 테스트하는 것은 어렵습니다. 공유 메모리에 액세스 할 수있는 여러 스레드는 일반적으로 상호 배제가 필요합니다. 우리는 일반적으로 잠금 장치를 사용하는 보호 메커니즘을 통해이를 달성합니다.

그러나 이것은 경쟁 조건, 라이브 잠금, 교착 상태 및 스레드 부족과 같은 많은 문제로 이어질 수 있습니다. 더욱이 이러한 문제는 간헐적입니다. 네이티브 스레드의 경우 스레드 스케줄링이 완전히 비결정 적이기 때문입니다.

따라서 이러한 문제를 결정적인 방식으로 감지 할 수있는 동시 프로그램에 대한 효과적인 테스트를 작성하는 것은 참으로 어려운 일입니다!

2.3. 스레드 인터리빙 분석

네이티브 스레드는 운영 체제에 의해 예측할 수없이 예약 될 수 있습니다. 이러한 스레드가 공유 데이터에 액세스하고 수정하는 경우 흥미로운 스레드 인터리빙이 발생 합니다. 이러한 인터리빙 중 일부는 완전히 수용 가능할 수 있지만 다른 일부는 최종 데이터를 바람직하지 않은 상태로 남겨 둘 수 있습니다.

예를 들어 보겠습니다. 모든 스레드에서 증가하는 글로벌 카운터가 있다고 가정합니다. 처리가 끝날 때까지이 카운터의 상태가 실행 된 스레드 수와 정확히 동일하기를 바랍니다.

private int counter; public void increment() { counter++; }

이제 Java에서 원시 정수증가시키는 것은 원자 적 연산이 아닙니다 . 값을 읽고, 늘리고, 마지막으로 저장하는 것으로 구성됩니다. 여러 스레드가 동일한 작업을 수행하는 동안 가능한 많은 인터리빙이 발생할 수 있습니다.

이 특정 인터리빙은 완전히 수용 가능한 결과를 생성하지만, 다음은 어떨까요?

이것은 우리가 기대 한 것이 아닙니다. 이제 이보다 훨씬 더 복잡한 코드를 실행하는 수백 개의 스레드를 상상해보십시오. 이것은 스레드가 인터리브되는 상상할 수없는 방식을 야기 할 것입니다.

이 문제를 방지하는 코드를 작성하는 방법에는 여러 가지가 있지만이 튜토리얼의 주제는 아닙니다. 잠금을 사용한 동기화는 일반적인 것 중 하나이지만 경쟁 조건과 관련된 문제가 있습니다.

3. 멀티 스레드 코드 테스트

멀티 스레드 코드 테스트의 기본 과제를 이해 했으므로 이제이를 극복하는 방법을 살펴 보겠습니다. 간단한 사용 사례를 만들고 동시성과 관련된 많은 문제를 가능한 한 많이 시뮬레이션하려고합니다.

가능한 모든 수를 유지하는 간단한 클래스를 정의하는 것으로 시작하겠습니다.

public class MyCounter { private int count; public void increment() { int temp = count; count = temp + 1; } // Getter for count }

이것은 겉보기에 무해한 코드이지만 스레드로부터 안전하지 않다는 것을 이해하는 것은 어렵지 않습니다 . 이 클래스와 함께 동시 프로그램을 작성하면 결함이있을 수 있습니다. 여기서 테스트의 목적은 이러한 결함을 식별하는 것입니다.

3.1. 비 동시 부품 테스트

경험상 항상 동시 동작에서 코드를 분리하여 테스트하는 것이 좋습니다 . 이는 동시성과 관련되지 않은 코드에 다른 결함이 없는지 합리적으로 확인하기위한 것입니다. 우리가 어떻게 할 수 있는지 봅시다 :

@Test public void testCounter() { MyCounter counter = new MyCounter(); for (int i = 0; i < 500; i++) { counter.increment(); } assertEquals(500, counter.getCount()); }

여기에는 별다른 것이 없지만이 테스트는 적어도 동시성이 없을 때 작동한다는 확신을줍니다.

3.2. 동시성을 사용한 테스트 첫 시도

이번에는 동시 설정에서 동일한 코드를 다시 테스트 해 보겠습니다. 여러 스레드를 사용하여이 클래스의 동일한 인스턴스에 액세스하고 어떻게 작동하는지 살펴 보겠습니다.

@Test public void testCounterWithConcurrency() throws InterruptedException { int numberOfThreads = 10; ExecutorService service = Executors.newFixedThreadPool(10); CountDownLatch latch = new CountDownLatch(numberOfThreads); MyCounter counter = new MyCounter(); for (int i = 0; i  { counter.increment(); latch.countDown(); }); } latch.await(); assertEquals(numberOfThreads, counter.getCount()); }

이 테스트는 여러 스레드가있는 공유 데이터에서 작업하려고하기 때문에 합리적입니다. 스레드 수를 10 개처럼 낮게 유지하면 거의 항상 통과하는 것을 알 수 있습니다. 흥미롭게도 스레드 수 (예 : 100 개)를 늘리기 시작하면 테스트가 대부분 실패하기 시작하는 것을 볼 수 있습니다 .

3.3. 동시성으로 더 나은 테스트 시도

이전 테스트에서 코드가 스레드로부터 안전하지 않음이 밝혀졌지만이 젖꼭지에 문제가 있습니다. 기본 스레드가 비 결정적 방식으로 인터리브되기 때문에이 테스트는 결정적이지 않습니다. 우리 프로그램에 대해이 테스트를 신뢰할 수 없습니다.

우리에게 필요한 것은 훨씬 적은 수의 스레드로 결정적인 방식으로 동시성 문제드러 낼 수 있도록 스레드의 인터리빙을 제어하는 방법입니다. 테스트중인 코드를 약간 수정하여 시작하겠습니다.

public synchronized void increment() throws InterruptedException { int temp = count; wait(100); count = temp + 1; }

여기서는 메서드를 동기화 하고 메서드 내 두 단계 사이에 대기를 도입했습니다. 동기화 하나의 스레드 만이 수정할 수있는 키워드를 보장하지만 카운트 한 번에 변수를하고, 대기 소개하고 각 스레드 실행 사이에 지연.

테스트하려는 코드를 반드시 수정할 필요는 없습니다. 그러나 스레드 스케줄링에 영향을 줄 수있는 방법이 많지 않기 때문에 여기에 의존하고 있습니다.

이후 섹션에서는 코드를 변경하지 않고이를 수행하는 방법을 살펴 보겠습니다.

Now, let's similarly test this code as we did earlier:

@Test public void testSummationWithConcurrency() throws InterruptedException { int numberOfThreads = 2; ExecutorService service = Executors.newFixedThreadPool(10); CountDownLatch latch = new CountDownLatch(numberOfThreads); MyCounter counter = new MyCounter(); for (int i = 0; i  { try { counter.increment(); } catch (InterruptedException e) { // Handle exception } latch.countDown(); }); } latch.await(); assertEquals(numberOfThreads, counter.getCount()); }

Here, we're running this just with just two threads, and the chances are that we'll be able to get the defect we've been missing. What we've done here is to try achieving a specific thread interleaving, which we know can affect us. While good for the demonstration, we may not find this useful for practical purposes.

4. Testing Tools Available

As the number of threads grows, the possible number of ways they may interleave grows exponentially. It's just not possible to figure out all such interleavings and test for them. We have to rely on tools to undertake the same or similar effort for us. Fortunately, there are a couple of them available to make our lives easier.

There are two broad categories of tools available to us for testing concurrent code. The first enables us to produce reasonably high stress on the concurrent code with many threads. Stress increases the likelihood of rare interleaving and, thus, increases our chances of finding defects.

The second enables us to simulate specific thread interleaving, thereby helping us find defects with more certainty.

4.1. tempus-fugit

The tempus-fugit Java library helps us to write and test concurrent code with ease. We'll just focus on the test part of this library here. We saw earlier that producing stress on code with multiple threads increases the chances of finding defects related to concurrency.

While we can write utilities to produce the stress ourselves, tempus-fugit provides convenient ways to achieve the same.

Let's revisit the same code we tried to produce stress for earlier and understand how can we achieve the same using tempus-fugit:

public class MyCounterTests { @Rule public ConcurrentRule concurrently = new ConcurrentRule(); @Rule public RepeatingRule rule = new RepeatingRule(); private static MyCounter counter = new MyCounter(); @Test @Concurrent(count = 10) @Repeating(repetition = 10) public void runsMultipleTimes() { counter.increment(); } @AfterClass public static void annotatedTestRunsMultipleTimes() throws InterruptedException { assertEquals(counter.getCount(), 100); } }

Here, we are using two of the Rules available to us from tempus-fugit. These rules intercept the tests and help us apply the desired behaviors, like repetition and concurrency. So, effectively, we are repeating the operation under test ten times each from ten different threads.

As we increase the repetition and concurrency, our chances of detecting defects related to concurrency will increase.

4.2. Thread Weaver

Thread Weaver is essentially a Java framework for testing multi-threaded code. We've seen previously that thread interleaving is quite unpredictable, and hence, we may never find certain defects through regular tests. What we effectively need is a way to control the interleaves and test all possible interleaving. This has proven to be quite a complex task in our previous attempt.

Let's see how Thread Weaver can help us here. Thread Weaver allows us to interleave the execution of two separate threads in a large number of ways, without having to worry about how. It also gives us the possibility of having fine-grained control over how we want the threads to interleave.

Let's see how can we improve upon our previous, naive attempt:

public class MyCounterTests { private MyCounter counter; @ThreadedBefore public void before() { counter = new MyCounter(); } @ThreadedMain public void mainThread() { counter.increment(); } @ThreadedSecondary public void secondThread() { counter.increment(); } @ThreadedAfter public void after() { assertEquals(2, counter.getCount()); } @Test public void testCounter() { new AnnotatedTestRunner().runTests(this.getClass(), MyCounter.class); } }

Here, we've defined two threads that try to increment our counter. Thread Weaver will try to run this test with these threads in all possible interleaving scenarios. Possibly in one of the interleaves, we will get the defect, which is quite obvious in our code.

4.3. MultithreadedTC

MultithreadedTC is yet another framework for testing concurrent applications. It features a metronome that is used to provide fine control over the sequence of activities in multiple threads. It supports test cases that exercise a specific interleaving of threads. Hence, we should ideally be able to test every significant interleaving in a separate thread deterministically.

Now, a complete introduction to this feature-rich library is beyond the scope of this tutorial. But, we can certainly see how to quickly set up tests that provide us the possible interleavings between executing threads.

Let's see how can we test our code more deterministically with MultithreadedTC:

public class MyTests extends MultithreadedTestCase { private MyCounter counter; @Override public void initialize() { counter = new MyCounter(); } public void thread1() throws InterruptedException { counter.increment(); } public void thread2() throws InterruptedException { counter.increment(); } @Override public void finish() { assertEquals(2, counter.getCount()); } @Test public void testCounter() throws Throwable { TestFramework.runManyTimes(new MyTests(), 1000); } }

Here, we are setting up two threads to operate on the shared counter and increment it. We've configured MultithreadedTC to execute this test with these threads for up to a thousand different interleavings until it detects one which fails.

4.4. Java jcstress

OpenJDK maintains Code Tool Project to provide developer tools for working on the OpenJDK projects. There are several useful tools under this project, including the Java Concurrency Stress Tests (jcstress). This is being developed as an experimental harness and suite of tests to investigate the correctness of concurrency support in Java.

Although this is an experimental tool, we can still leverage this to analyze concurrent code and write tests to fund defects related to it. Let's see how we can test the code that we've been using so far in this tutorial. The concept is pretty similar from a usage perspective:

@JCStressTest @Outcome(id = "1", expect = ACCEPTABLE_INTERESTING, desc = "One update lost.") @Outcome(id = "2", expect = ACCEPTABLE, desc = "Both updates.") @State public class MyCounterTests { private MyCounter counter; @Actor public void actor1() { counter.increment(); } @Actor public void actor2() { counter.increment(); } @Arbiter public void arbiter(I_Result r) { r.r1 = counter.getCount(); } }

Here, we've marked the class with an annotation State, which indicates that it holds data that is mutated by multiple threads. Also, we're using an annotation Actor, which marks the methods that hold the actions done by different threads.

Finally, we have a method marked with an annotation Arbiter, which essentially only visits the state once all Actors have visited it. We have also used annotation Outcome to define our expectations.

Overall, the setup is quite simple and intuitive to follow. We can run this using a test harness, given by the framework, that finds all classes annotated with JCStressTest and executes them in several iterations to obtain all possible interleavings.

5. Other Ways to Detect Concurrency Issues

Writing tests for concurrent code is difficult but possible. We've seen the challenges and some of the popular ways to overcome them. However, we may not be able to identify all possible concurrency issues through tests alone — especially when the incremental costs of writing more tests start to outweigh their benefits.

Hence, together with a reasonable number of automated tests, we can employ other techniques to identify concurrency issues. This will boost our chances of finding concurrency issues without getting too much deeper into the complexity of automated tests. We'll cover some of these in this section.

5.1. Static Analysis

Static analysis refers to the analysis of a program without actually executing it. Now, what good can such an analysis do? We will come to that, but let's first understand how it contrasts with dynamic analysis. The unit tests we've written so far need to be run with actual execution of the program they test. This is the reason they are part of what we largely refer to as dynamic analysis.

Please note that static analysis is in no way any replacement for dynamic analysis. However, it provides an invaluable tool to examine the code structure and identify possible defects long before we even execute the code. The static analysis makes use of a host of templates that are curated with experience and understanding.

While it's quite possible to just look through the code and compare against the best practices and rules we've curated, we must admit that it's not plausible for larger programs. There are, however, several tools available to perform this analysis for us. They are fairly mature, with a vast chest of rules for most of the popular programming languages.

A prevalent static analysis tool for Java is FindBugs. FindBugs looks for instances of “bug patterns”. A bug pattern is a code idiom that is quite often an error. This may arise due to several reasons like difficult language features, misunderstood methods, and misunderstood invariants.

FindBugs inspects the Java bytecode for occurrences of bug patterns without actually executing the bytecode. This is quite convenient to use and fast to run. FindBugs reports bugs belonging to many categories like conditions, design, and duplicated code.

It also includes defects related to concurrency. It must, however, be noted that FindBugs can report false positives. These are fewer in practice but must be correlated with manual analysis.

5.2. Model Checking

Model Checking is a method of checking whether a finite-state model of a system meets a given specification. Now, this definition may sound too academic, but bear with it for a while!

We can typically represent a computational problem as a finite-state machine. Although this is a vast area in itself, it gives us a model with a finite set of states and rules of transition between them with clearly defined start and end states.

Now, the specification defines how a model should behave for it to be considered as correct. Essentially, this specification holds all the requirements of the system that the model represents. One of the ways to capture specifications is using the temporal logic formula, developed by Amir Pnueli.

While it's logically possible to perform model checking manually, it's quite impractical. Fortunately, there are many tools available to help us here. One such tool available for Java is Java PathFinder (JPF). JPF was developed with years of experience and research at NASA.

Specifically, JPF is a model checker for Java bytecode. It runs a program in all possible ways, thereby checking for property violations like deadlock and unhandled exceptions along all possible execution paths. It can, therefore, prove to be quite useful in finding defects related to concurrency in any program.

6. Afterthoughts

By now, it shouldn't be a surprise to us that it's best to avoid complexities related to multi-threaded code as much as possible. Developing programs with simpler designs, which are easier to test and maintain, should be our prime objective. We have to agree that concurrent programming is often necessary for modern-day applications.

However, we can adopt several best practices and principles while developing concurrent programs that can make our life easier. In this section, we will go through some of these best practices, but we should keep in mind that this list is far from complete!

6.1. Reduce Complexity

Complexity is a factor that can make testing a program difficult even without any concurrent elements. This just compounds in the face of concurrency. It's not difficult to understand why simpler and smaller programs are easier to reason about and, hence, to test effectively. There are several best patterns that can help us here, like SRP (Single Responsibility Pattern) and KISS (Keep It Stupid Simple), to just name a few.

Now, while these do not address the issue of writing tests for concurrent code directly, they make the job easier to attempt.

6.2. Consider Atomic Operations

Atomic operations are operations that run completely independently of each other. Hence, the difficulties of predicting and testing interleaving can be simply avoided. Compare-and-swap is one such widely-used atomic instruction. Simply put, it compares the contents of a memory location with a given value and, only if they are the same, modifies the contents of that memory location.

Most modern microprocessors offer some variant of this instruction. Java offers a range of atomic classes like AtomicInteger and AtomicBoolean, offering the benefits of compare-and-swap instructions underneath.

6.3. Embrace Immutability

In multi-threaded programming, shared data that can be altered always leaves room for errors. Immutability refers to the condition where a data structure cannot be modified after instantiation. This is a match made in heaven for concurrent programs. If the state of an object can't be altered after its creation, competing threads do not have to apply for mutual exclusion on them. This greatly simplifies writing and testing concurrent programs.

However, please note that we may not always have the liberty to choose immutability, but we must opt for it when it's possible.

6.4. Avoid Shared Memory

Most of the issues related to multi-threaded programming can be attributed to the fact that we have shared memory between competing threads. What if we could just get rid of them! Well, we still need some mechanism for threads to communicate.

There are alternate design patterns for concurrent applications that offer us this possibility. One of the popular ones is the Actor Model, which prescribes the actor as the basic unit of concurrency. In this model, actors interact with each other by sending messages.

Akka is a framework written in Scala that leverages the Actor Model to offer better concurrency primitives.

7. Conclusion

이 튜토리얼에서는 동시 프로그래밍과 관련된 몇 가지 기본 사항을 다뤘습니다. Java의 다중 스레드 동시성에 대해 특히 자세히 논의했습니다. 우리는 특히 공유 데이터를 사용하여 이러한 코드를 테스트하는 동안 우리에게 제시되는 문제를 겪었습니다. 또한 동시 코드를 테스트하는 데 사용할 수있는 몇 가지 도구와 기술을 살펴 보았습니다.

또한 자동화 된 테스트 외에 도구 및 기술을 포함하여 동시성 문제를 방지하는 다른 방법에 대해서도 논의했습니다. 마지막으로 동시 프로그래밍과 관련된 몇 가지 프로그래밍 모범 사례를 살펴 보았습니다.

이 기사의 소스 코드는 GitHub에서 찾을 수 있습니다.