Blocking 방식인 "synchronized"
Non-Blocking 방식인 "CAS"
무엇이 더 좋다기보다 서로 사용해야할 상황이 다르다는 것을 알게됐다.
그렇다면 synchronized와 cas를 어떤 상황에 사용하는게 좋을지 알아보자.
동기
공부중에 이런 궁금증이 생겨났다.
블락 방식은 단순히 안전하고 느릴까? 논블락은 블락보다 항상 빠를까? 요청 실패 횟수가 많으면 성능에 영향을 미치진 않을까?
그래서 이런 고민들을 해결하기 위해 스스로 위 생각들에 대해 이론을 정리하고 실험해보기로 했다.
아래 이론은 내가 공부하고 정리한 내용이기 때문에 정답은 아닐 수 있다 참고용으로만 봐주시길 바란다.
이론
이전의 카운트업 예시처럼, 멀티스레드로 공유변수에 대해 어떤 연산을 N회 수행하는 코드가 있다고 가정하자. 연산 시간은 T sec 만큼 소요된다고 하자.
synchronized 동작 시간 수식
연산에 synchronized를 사용한다면, 시간이 얼마나 걸릴까? 우선 스레드 하나당 연산 횟수와 시간을 곱한 만큼 시간이 걸릴 것이다.
-> 동기화가 돼서 개별 연산시간이 보장되므로 그 시간동안은 다른 작업을 못하기 때문.
- 총 연산 수행 시간 = (동작하는 thread 수) X (연산 횟수) X (연산 시간)
- 총 컨택스트 스위칭 소요 시간 = (context switching 횟수 X context switching 소요 시간)
- 동작 시간 = 총 연산 수행 시간 + 총 컨택스트 스위칭 소요 시간 = (동작하는 thread 수) X (연산 횟수) X (연산 시간) + (context switching 횟수 X context switching 소요 시간)
여기서 context switching 횟수는 최대가 연산 횟수 이므로
- context switching 횟수 <= 총 연산 횟수
synchronized 동작 시간 = 총 연산 수행 시간 + 총 컨택스트 스위칭 소요 시간
= (동작하는 thread 수) X (연산 횟수) X (연산 시간) + (context switching 횟수 X context switching 소요 시간)
= (동작하는 thread 수) X (연산 횟수) X (연산 시간) + ((동작하는 thread 수) X (연산 횟수) X (1 - a) X context switching 소요 시간)
= (동작하는 thread 수) X (연산 횟수) X(연산 시간 + (1 - a) X context switching 소요 시간)
cas 동작 시간 수식
- 총 요청 시간 = (요청 횟수) X (요청 대기 시간)
- 스레드 당 수행 시간 = (요청 횟수) X (요청 대기 시간) + (연산 횟수) X (연산 시간)
- 총 스레드 당 수행 시간 = (스레드1 수행 시간) + (스레드2 수행 시간) + ... + (스레드n 수행 시간)
평균 총 스레드 당 수행 시간 = (스레드 수) X (요청 횟수) X (요청 대기 시간) + (연산 횟수) X (연산 시간)
위 식을 통해 유추할 수 있는 내용
1. 총 요청 시간이 무의미해질만큼 스레드 수가 늘어나면 CAS가 더 빨라지게 된다.
2. 스레드 개수 고정일때는 요청 횟수를 조정하면 CAS가 더 빨라진다.
-> 다만 너무 재요청이 없다면 성능이 저하될 것이다. 그리고 재요청 주기가 너무 짧으면 자주 실패가 발생하므로 오버헤드가 발생한다.
2번 문제를 해결하기 위해 재시도 횟수를 조정하는 방법을 이용해서 성능을 더 향상 시킬 수 있다.
CAS 연산이 연속으로 실패할 경우 재시도 간격을 점차 증가시켜나간다면 오버헤드가 줄 수 있다.
이러한 방식을 지수 백오프 방식이라고 한다. 이 방법을 사용해서 문제를 해결해 보겠다.
실험
0. 세팅
- m1 macbook pro
- 8코어 CPU
- 8GB RAM
- java 11
1. CAS와 개선된 CAS 그리고 synchronized를 비교하는 카운트업 예제 코드 작성
package compare;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Created by Robin on 2024/04/18.
* Description :
*/
public class CasVsImprovedCasVsSynchronizedExample {
private static final int NUM_THREADS= 10;
private static final int NUM_INCREMENTS= 10_000;
private static final int MAX_RETRIES = 10; // 최대 재시도 횟수
private static final int INITIAL_BACKOFF = 1; // 초기 백오프 값 (밀리초)
private static AtomicInteger casCounter = new AtomicInteger(0);
private static int syncCounter = 0;
public static void main(String[] args) throws InterruptedException {
long casTime = measureCasTime();
long improvedCasTime = measureImprovedCasTime();
long syncTime = measureSyncTime();
System.out.println("CAS 알고리즘 시간: " + casTime + "ms");
System.out.println("개선된 CAS 알고리즘 시간: " + improvedCasTime + "ms");
System.out.println("synchronized 키워드 시간: " + syncTime + "ms");
}
private static long measureCasTime() throws InterruptedException {
long startTime = System.currentTimeMillis();
Thread[] threads = new Thread[NUM_THREADS];
for (int i = 0; i < NUM_THREADS; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < NUM_INCREMENTS; j++) {
casCounter.incrementAndGet();
}
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
return System.currentTimeMillis() - startTime;
}
private static long measureImprovedCasTime() throws InterruptedException {
long startTime = System.currentTimeMillis();
Thread[] threads = new Thread[NUM_THREADS];
for (int i = 0; i < NUM_THREADS; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < NUM_INCREMENTS; j++) {
int retries = 0;
while (true) {
int currentValue = casCounter.get();
int newValue = currentValue + 1;
if (casCounter.compareAndSet(currentValue, newValue)) {
break; // CAS 성공 시 루프 종료
}
// CAS 실패 시 지수 백오프 적용
int backoff = (int) (INITIAL_BACKOFF * Math.pow(2, retries));
retries++;
try {
Thread.sleep(backoff);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
if (retries >= MAX_RETRIES) {
// 최대 재시도 횟수에 도달하면 종료
break;
}
}
}
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
return System.currentTimeMillis() - startTime;
}
private static long measureSyncTime() throws InterruptedException {
long startTime = System.currentTimeMillis();
Thread[] threads = new Thread[NUM_THREADS];
for (int i = 0; i < NUM_THREADS; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < NUM_INCREMENTS; j++) {
synchronized (CasVsSynchronizedExample.class) {
syncCounter++;
}
}
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
return System.currentTimeMillis() - startTime;
}
}
2. 스레드 수와 카운트 수를 조정하며 결과 확인
private static final int NUM_THREADS= 10;
private static final int NUM_INCREMENTS= 10_000;
CAS 알고리즘 시간: 16ms
개선된 CAS 알고리즘 시간: 40ms
synchronized 키워드 시간: 19ms
private static final int NUM_THREADS= 10;
private static final int NUM_INCREMENTS= 10_000_000;
CAS 알고리즘 시간: 5758ms
개선된 CAS 알고리즘 시간: 2254ms
synchronized 키워드 시간: 3806ms
private static final int NUM_THREADS= 1000;
private static final int NUM_INCREMENTS= 1_000_000;
CAS 알고리즘 시간: 64407ms
개선된 CAS 알고리즘 시간: 9581ms
synchronized 키워드 시간: 40683ms
실험 결과 및 결론
- 고정 연산에서 스레드가 늘어날 수록 synchronized에 비해 CAS가 더 느려진다.
- 스레드가 늘어나서 발생하는 컨택스트 스위칭 비용이 더 싸다
- 카운트 업 횟수가 늘어날 수록 synchronized에 비해 CAS가 더 느려진다.
- 이유는 CAS 연산이 계속 실패할수록 연산만큼 재시도 횟수도 늘어나기 때문이다.
- 지수 Backoff 를 적용한 CAS는 적은 수의 스레드 보다는 많은 스레드 처리 시 효과적이다.
- 스레드 수가 적은 경우에는 CAS의 재시도 횟수가 늘어나는 것이 더 큰 오버헤드를 발생시킬 수 있지만, 스레드 수가 많은 경우에는 CAS의 성능을 개선할 수 있다. 이러한 이유로 지수 백오프를 적용한 CAS는 적은 수의 스레드보다는 많은 스레드를 처리할 때 더 효과적이다.
이번에도 부족하지만 봐주셔서 감사합니다.
제가 고민한 내용이 누군가에게 도움이 되었으면 좋겠습니다.
'개발 > 백앤드' 카테고리의 다른 글
[1] [개인공부] 스레드 세이프티(Thread safety) 이해. (0) | 2024.04.10 |
---|---|
[0] [개인 공부] 스레드 세이프티(Thread safety) (0) | 2024.03.26 |
[2] 인X타그램에서 게시물 작성 시 어떤일이 일어날까? - multipart/form-data편 (0) | 2024.03.19 |
[1] 인X타그램에서 게시물 작성 시 어떤일이 일어날까? - multipart/form-data편 (1) | 2024.03.17 |
[MySQL] 레코드 기반의 잠금이란? (0) | 2023.03.05 |