아래의 링크는 JUnit 5에서 JUnit 3, 4의 테스트 코드 실행에 병렬화를 지원하자고 하는 이슈입니다.
https://github.com/junit-team/junit5/issues/2229
갑자기 해당 이슈를 설명하는 이유는 제가 기여하고 있는 이슈이기 때문입니다....ㅎ
해당 이슈를 작업 중인데 아직 동시성과 병렬성을 완벽하게 이해하지 못 해, 이번 글을 통해 이해 해보려 합니다.
오픈소스 기여의 길은 험난하네요... 조금이라도 모르는 개념이 있으면 날카롭게 지적이 들어옵니다.
1. 동시성과 병렬성
우선 동시성과 병렬성의 공통점은 동시에 실행되는 것처럼 보인다는 것입니다.
하지만, 자세히 살펴보면 동시성은 정확히 말해 동시에 실행되는 것은 아닙니다.
여러 작업들이 시간을 두고 실행이 되어 사용자로 하여끔 동시에 실행되는 것처럼 보이게 하는 것입니다.
하지만, 병렬성은 여러 작업들이 동시에 실행되는 것입니다.
그림을 보면 순차, 동시성, 병렬성이 나와있습니다.
순차적으로 하는 것은 하나의 작업이 끝나면 그 다음 작업으로 넘어가는 것을 볼 수 있습니다.
그리고 동시성은 하나의 스레드에서 시간을 두고 여러 작업을 진행해 동시에 진행되는 것처럼 보이게 합니다.
그에 비해 병렬성은 두 개의 스레드에서 동시에 처리하는 것을 볼 수 있습니다.
동시성 문제란?
그러면 동시성 문제는 무엇일까요?
동시성 문제는 여러 프로세스나 스레드가 동시에 같은 데이터에 접근하려 할 때 발생해 개발자의 의도와 다르게 작동하는 문제를 뜻합니다.
1. 원자성 문제
원자성 문제는 여러 개의 연산이 하나의 단위 작업으로 취급되어야 할 때 작업이 중간에 중단되거나 일부만 실행되는 상황을 말합니다.
아래의 코드는 싱글 코어에서 여러 개의 스레드를 실행시킬 때 발생한 원자성 문제를 나타낸 코드입니다.
public class Counter {
private int count = 0;
public void increment() {
int temp = count;
temp = temp + 1;
count = temp;
}
public long getCount() {
return count;
}
}
---
public class ConcurrencyExample {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread threadA = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
Thread threadB = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
});
threadA.start();
threadB.start();
threadA.join();
threadB.join();
System.out.println("Final count: " + counter.getCount());
}
}
코드에 대해 간단히 설명을 하자면, 스레드 A와 스레드 B가 count라는 공유 자원에 접근해 increment api를 실행시키는 로직입니다.
ThreadA에서 1,000번 ThreadB에서 1,000번 실행을 시키므로 counter의 count 값은 2,000이 되어야 합니다.
하지만 실제로 확인을 해보니 2,000보다 작은 값이 나왔습니다.
그러면 왜 해당 로직에서 Final count의 값이 1318이 나왔는지 설명하겠습니다.
- 스레드 A가 count 값(0)을 읽음
- 컨텍스트 스위칭 발생, 스레드 B로 전환
- 스레드 B가 count 값(여전히 0)을 읽음
- 스레드 B가 값을 1 증가시키고 저장 (count는 1)
- 스레드 A로 다시 전환
- 스레드 A가 이전에 읽은 값(0)을 1 증가시키고 저장 (count는 여전히 1)
쉽게 말을 하면 하나의 스레드에서 count의 값을 변경하는 도중에 다른 스레드로 전환이 돼, 문제가 발생하는 것입니다.
즉, 원자적으로 처리를 하지 못해 문제가 발생하는 것입니다.
이때 원자적이란?
하나의 작업이 중간에 끊기지 않고 완전히 수행되거나 아예 수행되지 않은 것입니다.
쉽게 트랜잭션의 원자성을 생각하셔도 됩니다.
2. 가시성 문제
가시성 문제는 여러 스레드가 공유 변수에 접근할 때 한 스레드에서 변경한 값이 다른 스레드에 즉시 보이지 않는 현상을 말합니다.
그림과 함께 설명하겠습니다.
각 CPU는 자체 cache를 가지고 있어, 하나의 core에서 변수를 수정해도 다른 코어의 캐시에는 즉시 반영이 안 될수 있습니다. 즉, core 1에서 공유 변수의 값을 2로 변경했는데 RAM에 반영하지 않았다면 core 2는 공유 변수의 값을 조회할 때 2가 아닌 이전의 값으로 조회를 합니다.
이 것이 가시성 문제입니다.
동시성 문제 해결 방법
동시성 문제를 해결하는 방법에는 여러 가지 방법이 있습니다.
외부 서비스를 이용하지 않고 Java를 통해 해결하는 방법에 대해 말씀드리겠습니다.
1. synchronized
영어를 잘 하시는 분이라면 이미 뜻을 알고 계셨을 수도 있습니다.
동기화 됨이라는 뜻을 가졌습니다.
synchronized 키워드를 메서드, 변수에 붙이게 되면 여러 스레드가 동시에 접근을 하지 못 합니다.
즉, 경쟁 상태를 방지하는 것입니다.
경쟁상태란?
여러 개의 프로세스가 공유 자원에 동시에 접근할 때 실행 순서에 따라 결과값이 달라질 수 있는 현상을 의미합니다.
하지만, synchronized는 특정 블록 전체를 lock 하기 때문에 다른 thread는 아무 작업을 못 하고 기다리는 상황이 되어 낭비가 심하다.
synchronized 키워드는 JVM에서 MonitorEnter과 MoniterExit라는 바이트코드 명령어로 변환됩니다.
- MoniterEnter
- 스레드가 모니터 락을 획득하고 임계영역에 진입한다.
- 만약 락이 다른 스레드에 의해 점유 중이면, 락을 획득할 때까지 대기
- MoniterExit
- 스레드가 락을 해제하고 임계 영역을 벗어난다.
- 대기중인 스레드가 락을 획득할 수 있다.
2. volatile
volatile을 사용하면 가시성 문제를 해결할 수 있습니다.
기존 가시성 문제의 원인은 RAM이 아닌 cpu의 cache에 데이터를 저장해, 다른 cpu에서 읽지 못 하는 것이 문제였는데 volatile을 사용하면 cache에 데이터를 저장하는 것이 아니라 RAM에 저장을 하게 됩니다.
그리고 조회또한 RAM에서 조회를 하게 됩니다.
그로 인해 cpu의 cache로 인한 가시성 문제를 해결할 수 있습니다.
3. Atomic type
Atomic Type에는 여러가지 타입들이 있습니다.
아래의 링크를 보면 java의 concurrent.atomic 패키지 아래에 있는 클래스들을 볼 수 있습니다.
https://docs.oracle.com/javase/8/docs/api///?java/util/concurrent/atomic/package-summary.html
기존 synchronized는 경쟁 상태를 제거한다는 장점을 가지고 있었지만, 하나의 block을 전부 blocking 한다는 단점이 있었습니다. 하지만 Atomic은 non-blocking을 통해 동기화 문제를 해결한다는 특징을 가지고 있습니다.
그러기 위해선 CAS 알고리즘을 알아야 합니다.
아래의 그림은 CAS 알고리즘을 나타낸 그림입니다.
인자로 기존 값과 변경할 값을 보냅니다.
그리고 현재 메모리가 가지고 있는 값(value)와 비교를 합니다.
비교를 한 후, 기존 값과 현재 메모리가 가지고 있는 값이 동일하면 true를 반환한 뒤, 값을 변경합니다.
만약 일치하지 않는다면 false를 반환한 뒤, 값을 변경하지 않습니다.
이때 false를 반환하는 경우에는 값이 원하는대로 변경되지 않은 것이기 때문에 무한 루프를 구성하여 변경된 값을 읽고 다시 같은 시도를 반복하는 로직을 고려해봐도 됩니다.
결국 synchronized, volatile, Atomic Type을 통해서 동시성 문제를 해결할 수 있는데 이는 Thread-safe한 상태로 만드는 것입니다.
Thread-safe란 공유 자원에 여러 개의 스레드, 프로세스가 접근해도 예상치 못한 결과나 오류 없이 개발자가 예상한 값이 정확하게 실행되는 것입니다.
클래스
그러면 다시 Comment로 돌아오겠습니다.
왜? CopyOnWriteArrayList를 사용했나요? 동시 접근이 없어보이는데 단순한 ArrayList로도 해결할 수 있을 것 같습니다.
우선 해당 comment를 이해하기 위해서는 CopyOnWriteArrayList에 대해 이해를 해야 합니다.
CopyOnWriteArrayList vs SynchronizedList
CopyOnWriteArrayList는 클래스 이름 그대로 쓰기 작업에서 복사본을 만들고 그 복사본을 수정한 후 참조를 교체하는 방식으로 진행하는 클래스입니다.
이때 쓰기 작업에서는 synchronized를 통해 상호 배제를 보장하기 때문에 여러 스레드가 접근해도 하나의 스레드를 제외한 나머지 스레드는 대기를 해야 합니다. 이로 인해 성능상 저하가 생길 수 있습니다.
public boolean add(E e) {
synchronized (lock) {
Object[] es = getArray();
int len = es.length;
es = Arrays.copyOf(es, len + 1);
es[len] = e;
setArray(es);
return true;
}
}
하지만 그에 비해 읽기 작업에서는 lock을 사용하지 않아 빠르게 읽을 수 있으며, volatile 키워드를 통해 최신 상태의 배열을 읽을 수 있습니다.
그에 비해 SynchronizedList는 모든 Api에 synchronized 키워드가 붙어 있습니다.
mutex를 통해 한 번에 하나의 스레드의 접근만 허용한 것을 볼 수 있습니다.
public int hashCode() {
synchronized (mutex) {return list.hashCode();}
}
public E get(int index) {
synchronized (mutex) {return list.get(index);}
}
public E set(int index, E element) {
synchronized (mutex) {return list.set(index, element);}
}
public void add(int index, E element) {
synchronized (mutex) {list.add(index, element);}
}
public E remove(int index) {
synchronized (mutex) {return list.remove(index);}
}
public int indexOf(Object o) {
synchronized (mutex) {return list.indexOf(o);}
}
public int lastIndexOf(Object o) {
synchronized (mutex) {return list.lastIndexOf(o);}
}
public boolean addAll(int index, Collection<? extends E> c) {
synchronized (mutex) {return list.addAll(index, c);}
}
쓰기, 읽기 작업에서 둘의 차이를 비교하자면:
우선 쓰기 작업의 경우 둘 다 한 번에 하나의 스레드의 접근만 허용하긴 하나, CopyOnWriteArrayList의 경우 복사를 하기 때문에 SynchronizedList를 사용하는 것이 성능 상 좋을 수 있습니다.
하지만 읽기 작업의 경우 CopyOnWriteArrayList의 경우 lock이 없으므로 SynchronizedList에 비해 성능상 장점을 보입니다.
다시 코멘트로 돌아와서 설명을 하면 우선 동시 접근이 없다고 하셨습니다.
우선 구현한 코드를 보겠습니다.
ExecutorService executorService = Executors.newFixedThreadPool(getThreadPoolSize());
RunnerExecutor runnerExecutor = new RunnerExecutor(engineExecutionListener);
List<CompletableFuture<Void>> futures = new ArrayList<>();
for (Iterator<TestDescriptor> iterator = engineDescriptor.getModifiableChildren().iterator(); iterator.hasNext();) {
TestDescriptor descriptor = iterator.next();
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
RunnerTestDescriptor testDescriptor = (RunnerTestDescriptor) descriptor;
try {
runnerExecutor.execute(testDescriptor);
}
catch (Exception e) {
engineExecutionListener.executionSkipped(testDescriptor, e.getMessage());
}
}, executorService);
futures.add(future);
iterator.remove();
}
CompletableFuture<Void> allOf = CompletableFuture.allOf(futures.toArray(new CompletableFuture<?>[0]));
try {
allOf.get();
}
코드가 너무 길어 주요한 부분만 가져왔습니다..
우선 ExecutorService를 통해 스레드 풀을 생성했습니다.
이때 newFixedThreadPool을 통해 고정된 크기의 스레드 풀을 생성했습니다.
그리고 CompletableFuture을 통해 비동기 작업을 실행시키고, 해당 작업의 결과를 추가 했습니다.
해당 코드를 한줄로 평가하자면, 멀티 스레드를 통한 비동기 로직입니다.
그러면 왜 해당 로직에서 동시 접근이 없을까요?
우선, 첫 번째로 execute 안에서 실행되는 task는 원자적입니다.
JUnit의 테스트는 원자적입니다.
즉, 하나의 테스트는 작업이 중간에 끊기지 않고, 완전히 수행되거나 아예 수행되지 않습니다.
두 번째로 별도의 별도의 CompletableFuture에서 실행되므로, 동시 접근이 없습니다.
이러한 이유로 동시 접근이 불가능 합니다.
아래의 그림은 병렬성과 비동기성을 비교한 그림입니다.
이러한 이유로 인해 메인테이너님이 단순한 ArrayList로도 접근이 가능하시다고 한겁니다.
ConcurrentHashMap vs SynchronizedMap
ConcurrentHashMap은 java8 이후로 개별 버킷 수준에서 동시성을 제어하는 방식으로 개선됐습니다.
그에 비해 SynchronizedMap은 모든 읽기 작업과 쓰기 작업에 대해 전체 맵을 잠급니다.
읽기 작업에서는 SynchronizedMap의 경우 전체 맵에 대한 lock을 설정합니다.
하지만, ConcurrentHashMap의 경우 락을 사용하지 않습니다.
쓰기 작업의 경우 SynchronizedMap의 경우 전체 맵에 대한 lock을 설정합니다.
하지만, ConcurrentHashMap의 경우 개별 버킷에 대한 락을 설정합니다. 그래서 다른 스레드도 병렬로 사용 가능합니다. 그리고 volatile 키워드를 통해 다른 스레드에서도 변경된 값을 읽을 수 있습니다.
추가 질문
가시성 문제에 대해 조금 더 자세히 설명해 주세요. 여러 스레드가 모두 한 CPU의 캐시 메모리를 읽으면 가시성 문제가 발생하지 않을 것 같은데, 어떻게 생각하시나요?