Skip to content

Commit

Permalink
week4: CAS and concurrent collection summary
Browse files Browse the repository at this point in the history
  • Loading branch information
sso9594 committed Oct 10, 2024
1 parent 20f3472 commit 843fa55
Show file tree
Hide file tree
Showing 3 changed files with 329 additions and 0 deletions.
210 changes: 210 additions & 0 deletions seungyong/11-CAS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
# 섹션 11. CAS - 동기화와 원자적 연산

## 원자적 연산

### 원자적 연산

해당 연산이 더 이상 나눌 수 없는 단위로 수행된다는 것을 의미 → 실행 or 실행 되지 않음

> 즉, 멀티스레드 상황에서 다른 스레드의 간섭 없이 안전하게 처리되는 연산
>
원자적 연산이 아니지만 멀티스레드 상황에서 문제가 발생할 것 같은 경우, 앞서 배운 `synchronized` 블럭 or `Lock` 등을 사용해 임계 영역 설정

### Volatile

volatile는 여러 CPU 사이에 발생하는 캐시 메모리와 메인 메모리의 동기화 문제를 해결한다.

- 원자적 연산이 아니여서 발생하는 문제를 해결해주지 않음

### Synchronized

synchronized를 사용하면 안전하게 임계영역을 설정하여 블록 안의 연산을 원자적 연산으로 처리한다.

### AtomicInteger

`Synchronized` 블럭 안에서 증가, 감소 연산을 하듯이 AtomicInteger를 사용하면 원자적인 Integer를 사용할 수 있다.

> AtomicInteger, AtomicLong, AtomicBoolean과 같이 다양한 AtomicXxx 클래스 존재
>
### 성능 비교

**일반적인 Integer를 사용한 증가연산**

- 가장 빠르다
- CPU 캐시 적극 활용
- 안전한 임계영역이 없고, volatile도 사용하지 않으므로 멀티스레드에서는 사용 불가

**Volatile를 사용한 증가연산**

- CPU 캐시를 사용하지 않고 메인 메모리 사용
- 임계 영역 사용 X → 멀티 스레드 환경에서 사용 불가
- 단일 스레드에선 일반 Integer보다 느림

**synchronized를 사용한 증가연산**

- `Synchronized` 를 사용하므로 멀티스레드 환경에서도 안전
- 조금 느리다

**AtomicInteger**

- 멀티스레드 상황에서 안전하게 사용 가능
- synchronized, Lock 사용 보다 성능이 빠름

> 왜?
>
AtomicInteger는 synchronized와 Lock을 사용하지 않고 원자적 연산을 수행

## CAS 연산

### Lock 기반 방식의 문제점

락은 특정 자원을 보호하기 위해 스레드가 해당 자원에 대한 접근 제한

- 락 획득과 반납의 과정 반복 → 오버헤드

### CAS(Compare-And-Swap, Compare-And-Set)

- 락을 사용하지 않기 때문에 lock-free
- 락을 완전히 대체 X, 작은 단위의 일부 영역에 적용

<aside>
💡

**compareAndSet(0, 1)**

antomicInteger가 가지고 있는 값이 현재 0 이면, 이 값을 1로 변경하는 메서드

- 현재 값이 0 이면 1로 변경하고, true 반환 (성공 시)
- 현재 값이 0 이 아니면 변경 X, false 반환 (실패 시)

**이 메서드는 원자적으로 실행됨 (CPU 하드웨어 차원에서 제공하는 기능)**

</aside>

위 메서드는 두가지 과정으로 나눌 수 있는데

- x001의 값 확인
- 읽은 값이 0 이면 1로 변경

이 두 과정을 CPU가 원자적인 명령으로 만들기 위해 다른 스레드가 x001의 값을 변경하지 못하게 막음

## CAS 연산2

```java
private static int incrementAndGet(AtomicInteger atomicInteger) {
int getValue;
boolean result;
do {
getValue = atomicInteger.get();
log("getValue: " + getValue);
result = atomicInteger.compareAndSet(getValue, getValue + 1);
log("result: " + result);
} while (!result);
return getValue + 1;
}
```

- 위 코드처럼 value값을 읽고, 읽은 value값이 메모리 value값과 같은지 확인을 한 후 값을 증가
- 증가하는 로직인 CAS 연산이므로 멀티스레드 환경에서도 안전
- CAS 성공 시 true 반환 후 do-while 탈출
- CAS 실패 시 false 반환 후 do-while 재시작

## CAS 연산3

2개의 스레드로 CAS 연산2의 코드를 실행하면

```java
start value = 0
18:13:37.623 [ Thread-1] getValue: 0
18:13:37.623 [ Thread-0] getValue: 0
18:13:37.625 [ Thread-1] result: true
18:13:37.625 [ Thread-0] result: false
18:13:37.731 [ Thread-0] getValue: 1
18:13:37.731 [ Thread-0] result: true
AtomicInteger resultValue: 2
```

- Thread-0의 첫번 째 시도는 실패를 하여 getValue의 값이 증가하지 않은 것을 볼 수 있다.
- Thread-1이 먼저 값을 올려 value의 값이 변경되었기 때문
- false 반환 후 재시도
- Thread-0의 두번 째 시도는 성공하여 getValue의 값이 증가

### CAS 문제점

충돌이 빈번하게 발생하는 환경에서는 성능에 문제가 발생

- 여러 스레드가 자주 동시에 동일한 변수의 값을 변경하려 하는 경우

### CAS와 Lock 방식 비교

| Lock | CAS |
| --- | --- |
| 비관적 접근법 | 낙관적 접근법 |
| 데이터에 접근하기 전 항상 락 획득 | 락 사용X 바로 데이터 접근 |
| 다른 스레드의 접근 제한 | 충돌 발생 시 재시도 |
| 다른 스레드가 방해할 것이다 가정 | 대부분은 충돌이 없을것이다 가정 |

> 언제 CAS를 사용하는게 좋을 까?
>
간단한 CPU 연산같이 빠르게 처리되면 충돌이 자주 발생하지 않으므로 빠른 처리가 가능한 연산

## CAS 락 구현

```java
public class SpinLock {
private final AtomicBoolean lock = new AtomicBoolean(false);
public void lock() {
log("락 획득 시도");
while (!lock.compareAndSet(false, true)) {
// 락을 획득할 때 까지 스핀 대기(바쁜 대기) 한다.
log("락 획득 실패 - 스핀 대기");
}
log("락 획득 완료");
}
public void unlock() {
lock.set(false);
log("락 반납 완료");
}
}
```

- CAS를 사용하지 않은 스핀락과 비교했을 때, 다음 두가지 연산이 원자적으로 묶여 임계영역이 뚫리는 일이 발생하지 않는다
- 락 사용 여부 확인
- 락의 값 변경

이런 방식의 락은 CPU가 `BLOCKED``WAITING` 으로 전이되지 않기 때문에 CPU 자원을 계속해서 사용하지만 그만큼 빠르게 락을 획득하고 실행할 수 있다.

- 임계 영역을 필요로 하지만 연산이 매우 짧을 경우 사용

## 정리

**CAS**

장점

- 낙관적 동기화: 락을 걸지 않고 안전하게 업데이트
- 충돌이 자주 발생하지 않을 것을 가정, 충돌이 적은 환경에서 높은 성능
- 락 프리(Lock-Free): 락 사용 X → 락 획득 대기시간이 적음, 스레드 블로킹 X → 병렬 처리 효율적

단점

- 충돌이 빈번한 경우: 계속해서 재시도 해야하며, CPU 자원 지속적 소모 → 오버헤드 발생
- 스핀락과 유사한 오버헤드: 많은 충돌 시 많은 재시도 → 성능 저하

**동기화 락**

장점

- 충돌 관리: 락 사용을 통해 하나의 스레드만 리소스 접근 → 충돌 X
- 안정성: 복잡한 상황에서도 락을 통해 일관성 있게 동작
- 스레드 대기: 락을 대기하는 스레드는 CPU 사용 X

단점

- 락 획득 대기시간: 스레드가 락 획득을 위해 대기 → 소모 시간 발생
- 컨텍스트 스위칭 오버헤드: 락 획득 대기와 획득 시점에서 스레드의 상태가 변경 → 컨텍스트 스위칭 발생
- 컨텍스트 스위칭으로 인한 오버헤드가 발생
119 changes: 119 additions & 0 deletions seungyong/12-concurrent-collection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# 섹션12. 동시성 컬렉션

## 동시성 컬렉션이 필요한 이유

스레드 세이프(Thread Safe): 여러 스레드가 동시에 접근해도 괜찮은 경우

java.util 패키지에 있는 컬렉션 프레임 워크는 스레드 세이프 할까?

### java.util 패키지는 스레드 세이프 하지 않다.

```java
public void add(Object e) {
elementData[size] = e;
sleep(100); // 멀티스레드 문제를 쉽게 확인하는 코드
size++;
}
```

이 메서드는 원자적이지 않다.

- 내부 배열에 데이터 추가
- size도 함께 하나 증가
- size 값 불러오기
- +1 하기

따라서 원자적이지 않은 연산을 멀티스레드 환경에서 사용하려면 `synchronized` 혹은 `Lock` 을 통해 동기화 해야함

## 프록시 도입

### 프록시(Proxy): 대리자, 대신 처리해주는 자

![image.png](resources/proxy.png)

- 프록시인 SyncProxyList는 원본인 BasicList와 똑같은 SimpleList 구현
- 클라이언트는 원본 구현체든 proxy 구현체는 상관 X
- 클라이언트 입장에서는 프록시든 원본이든 똑같은 SimpleList 구현체
- 프록시는 내부에 원본을 가지고 있음
- 필요한 일을 처리 한 후, 원본을 호출하는 구조로 구현 가능
- 여기서는 Synchronized 블럭을 감싸는 일을 함
- 프록시가 동기화 적용 됐으므로, 원본 코드도 동기화 적용됨

### 프록시 패턴

객체지향 디자인 패턴 중 하나

> 어떤 객체에 대한 접근을 제어하기 위해 그 객체의 대리인 또는 인터페이스 역할을 하는 객체 제공
>
- 접근 제어: 실제 객체에 대한 접근 제어 및 통제
- 성능 향상: 실제 객체의 생성을 지연 or 캐싱하여 성능 최적화
- 부가 기능 제공: 실제 객체에 추가적인 기능(로깅, 인증, 동기화 등)을 투명하게 제공

Ex) Spring AOP

## 자바 동시성 컬렉션 - synchronized

모든 자료구조에 `synchronized` 를 사용해 동기화 해두면 어떨까 싶지만

- 모든 동기화 방식은 성능과 트레이드 오프가 있다

결국 개발자가 정확히 필요성을 판단하고 필요한 경우에만 적용해야 한다.

### Collections.synchronizedList(new ArrayList<>());

```java
public static <T> List<T> synchronizedList(List<T> list) {
return new SynchronizedRandomAccessList<>(list);
}
```

SynchronizedRandomAccessList는 synchronized를 추가하는 프록시 역할 수행

- 클라이언트 → ArrayList
- 클라이언트 → SynchronizedRandomAccessList(프록시) → ArrayList

### 단점

- 동기화 오버헤드 발생
- 락을 사용하기 때문에 성능 저하가 발생할 수 밖에 없음
- 전체 컬렉션에 대해 동기화
- 잠금 경합이 증가하여 병렬 처리의 효율성 저하
- 특정 스레드가 컬렉션을 사용하면 다른 스레드는 대기해야함
- 정교한 동기화 불가능
- 컬렉션 전체에 synchronized를 걸기 때문에 동기화에 대한 최적화 불가능

## 자바 동시성 컬렉션 - 동시성 컬렉션

동시성 컬렉션: thread-safe한 컬렉션, 매우 정교한 매커니즘으로 효율적으로 처리

### 동시성 컬렉션의 종류

- List
- CopyOnWriteArrayList → ArrayList의 대안
- Set
- CopyOnWriteArraySet → HashSet의 대안
- ConcurrentSkipListSet → TreeSet의 대안(정렬된 순서 유지, Comparator 사용 가능)
- Map
- ConcurrentHashMap → HashMap의 대안
- ConcurrentSkipListMap → TreeMap의 대안(정렬된 순서 유지, Comparator 사용 가능)
- Queue
- ConcurrentLinkedQueue → 동시성 큐, 비 차단(non-blocking)큐
- Deque
- ConcurrentLinkedDeque → 동시성 데크, 비 차단(non-blocking)큐

### 스레드를 차단하는 블로킹 큐

- BlockingQueue
- ArrayBlockingQueue
- 크기가 고정된 블로킹 큐
- 공정(fair)모드 사용 가능, 사용 시 성능 저하
- LinkedBlockingQueue
- 크기가 무한하거나 고정된 블로킹 큐
- PriorityBlockingQueue
- 우선순위가 높은 요소를 먼저 처리하는 블로킹 큐
- SynchronousQueue
- 데이터를 저장하지 않는 블로킹 큐, 생산자가 데이터 추가 시 소비할 때 까지 대기
- 생산자 - 소비자 직접거래
- DelayQueue
- 지연된 요소를 처리하는 블로킹 큐
- 각 요소는 지정된 시간이 지난 후 소비 가능
Binary file added seungyong/resources/proxy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 843fa55

Please sign in to comment.