주의! 이글은 개인 공부 목적으로 작성된 포스팅입니다.
잘못된 내용이 있을 수 있음을 주의해주세요.
오늘은 비동기 처리를 동기화하는 법을 알아볼까 한다.
왜 비동기 처리에 있어서 동기화가 필요할까?
문제 1. 쓰레드 1번과 2번에서 int a라는 변수에 접근하면 어떤 일이 벌어질까?
public class Main2{
public static void main(String[] args) {
Task task = new Task();
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
}
}
class Card{
int a = 0;
}
class Task implements Runnable{
Card card = new Card();
@Override
public void run() {
for (int i = 0; i < 10000; i ++){
card.a ++;
}
System.out.println(Thread.currentThread().getName() + " : " + card.a);
}
}
위 코드의 정답은 어떻게 될까?
예측 불가
왜? 저 코드의 예상 결과의 끝에는 20000을 예상하는 사람들도 있을 것이다.
하지만 실제로 실행해보면 20000이 아닌 19486과 같이 20000이 아닌 수로 끝난다.
그럼 for 문에서 10000번이 안 돌아간 건가?
확인해보자.
놀랍게도 for 문은 정상 작동했다.
그럼 문제가 뭘 까?
가시성은 한 스레드에서 변경한 변수의 값이 다른 스레드에서 얼마나 빠르게 반영되는지를 나타낸다.
예를 들어 스레드 1에서 변수 a를 변경하면 스레드 2에서도 a의 변경을 인지할 수 있어야 한다.
동시성은 여러 스레드가 동시에 실행되는 환경에서 작업을 다루는 개념으로
이때 주의해야 할 점은 서로 같은 변수를 참고하면 가시성이 보장되어야 한다.
자 그럼 문제를 파악해보자.
우리가 만약 위 코드와 같이 for 문을 사용하면 동시성에 의해 아래와 같은 작업이 발생한다.
a라는 변수에 쓰레드가 동시에 접근하게 된다.
그렇게 되면 a++이라는 명령어를 수행하는 데 쓰레드간에 ++ 명령이 무시될 수 있다.
이는 결과적으로 20000이 아닌 예상하지 못한 값이 나오는 결과를 초래한다.
그럼 이걸 해결하기 위해서는 어떻게 해야 할 까?
간단하게 Synchronized를 사용하면 된다.
아래 사진을 보자.
위와 같이 Synchronized를 사용하면 먼저 작업을 시작한 메서드가 끝날 때 까지
다음 쓰레드에서 명령을 실행할 수 없고 동시성과 가시성을 보장한다.
public synchronized void addInteger(){
this.a++;
}
위와 같이 메서드를 선언하기만 하면 작업이 끝난다.
쉽지 않은가?
정답은 No!!
synchronized로 메서드를 실행하면 특정 쓰레드에서 작업이 진행될 때
다른 쓰레드에서 해당 작업을 실행하기 위해서 대기를 해야 한다.
이에 따라 쓰레드가 대기로 인한 성능이 떨어지고 전체적으로 프로그램의 성능이 떨어진다.
CPU 스케쥴링에서 CPU를 노는 시간 없이 작업시키기 위해 노력하는 우리에게 있어
쓰레드에서 특정 메서드를 실행하기 위해 대기를 한다? 용납할 수 없다.
그럼 어떻게 해결해야 할까?
이 부분은 지금도 이해가 필요하지만, 우선 이해된 대로 정리해볼까 한다.
물론, 시작하기 전 Atomic을 제외하고도 Locks, Volatile, Semaphore 등의 방법이 있지만
이번에는 Atomic class만 다룰 것이다.
면접에서 질문받았는데 바로"???"로 벙어리 되버렸다.
바로 적용해보자.
import java.util.concurrent.atomic.AtomicInteger;
public class Main{
public static void main(String[] args) {
Task task = new Task();
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
}
}
class Card{
AtomicInteger a = new AtomicInteger(0);
}
class Task implements Runnable{
Card card = new Card();
@Override
public void run() {
while (true){
int cur = card.a.get();
if (cur < 20000) {
int newcur = cur + 1;
card.a.compareAndSet(cur, newcur);
//만약 최근 값이 1이고 예상 값이 최근 값과 같으면 newcur 값으로 대치하고 true 리턴을 한다.
//다른 쓰레드에서 이를 바꾸면 비교하는 과정에서 값이 변하니 변경되지 않아 false 리턴
}else{
break;
}
}
System.out.println(card.a.get());
}
}
위와 같이 코드를 구성해보자.
결과는 아래와 같다.
20000
20000
쓰레드 1번과 2번에서 둘 다 똑같은 card.a의 값을 가지게 됐다.
가시성과 동시성이 보장된 예시이다.
Atomic Class는 CAS(Compare And Swap) 알고리즘을 활용해 동시성 작업을 진행한다.
먼저, CPU의 CacheMemory와 Main Memory에 저장된 값을 비교하고
일치하는 경우 새로운 값으로 교체한다.
다음 코드로 이해를 도와보자.
@Override
public void run() {
while (true){
/*1*/ int cur = card.a.get();
if (cur < 20000) {
/*2*/ int newcur = cur + 1;
/*3*/ card.a.compareAndSet(cur, newcur);
}else{
break;
}
}
주석 1번을 보자. 쓰레드에서 AtomicInteger의 값을 가져온다.
주석 2번을 보자. 쓰레드에서 새로운 변수에 기존 값에 1을 더한 값을 저장한다.
주석 3번을 보자. card.a의 값과 cur 값을 비교 후 똑같으면 새로운 값을 업데이트한다.
이게 뭔 소리야?
기존 card.a의 값이 0이라고 가정하자.
그리고 card.a.compareAndSet() 명령어를 수행하면
card.a의 값인 0과 card.a.get() 한 cur 변수의 값을 비교하는 거다.
그림으로 설명을 도와본다.
이미지 안에 CAS는 card.a.compareAndSet() 을 뜻한다.
위처럼 get()한 변수가 다른 쓰레드에서 먼저 변경해 기존 값과 비교했을 때
다르다는 점을 확인해 가시성을 보장하고 값을 변경하는 원리이다.
추가적으로 동시에 작업이 일어나기 때문에 synchronized 대비 성능이 좋다.
이외에도 다양한 메서드를 제공하지만 너무 많으니 Java Docs를 참고하자.
긴 글 읽어주셔 감사하고 피드백은 언제나 환영입니다.
감사합니다.