Concurrency in action - chapter 7

64
NHN NEXT 1기 이진우 ([email protected]) Lock-free 자료구조 설계 Chapter 7

Transcript of Concurrency in action - chapter 7

NHN NEXT 1기 이진우 ([email protected])

Lock-free 자료구조 설계

Chapter 7

blocking

• 작업이 끝날 때 까지 멈추는 것을 뜻함

–끝날 때 까지 함수가 리턴 되지 않음

– OS가 쓰레드를 멈출 수 있음 (I/O 작업 등...)

• 자료구조 및 알고리즘에서는,

–뮤텍스, 상태변수, std::future 등을 쓰는 경우

nonblocking 자료구조의 종류

• Obstruction-free –독립적 실행 중에는 일정 스텝 안에 종료 보장

• Lock-free –실질적으로 (extreamly often) 일정 스텝 안에 종료 보장

• Wait-free –반드시 항상 일정 스텝 안에 종료 보장

출처 : The Art of Multiprocessor Programming

Spin-lock

• blocking 함수 호출은 없다

• lock-free는 아니다

...이런 상황이 생기기도 합니다.

Thread 1

Thread 2 spin spin spin spin spin spin…

SpinLock잠금

쓰레드 suspend

Lock-free 자료구조

• 다수의 쓰레드가 자료구조에 접근 가능

–최소한 하나의 쓰레드는 항상 전진함을 보장

–다른 쓰레드가 동일한 작업 중 멈췄을 때

• 항상 성공하면 : lock-free

• 멈출 수 있으면 : lock-free가 아님

• 너무 많은 쓰레드가 경쟁하면?

– starvation 발생 가능

Wait-free 자료구조

• 정해진 시도 횟수 안에 완료

–무한히 많은 쓰레드가 경쟁 해도 시간 내 완료

–모든 쓰레드가 항상 전진함을 보장

• 구현이 매우 어렵다

• 실질적으로 성능이 좋지는 않다

Compare-and-swap

• 상당수의 Lock-free 알고리즘이 CAS를 사용

–일반적으로는 single-word CAS를 의미

• Double-word CAS

– 32bit 시스템에서는 64bit CAS

– 64bit 시스템에서는 128bit CAS

–최신 CPU에서만 일부 지원

–컴파일러 옵션 설정이 필요한 경우가 있음

Compare-and-swap

• C++11 에서 구현 된 형태 • 주의) 일반적인 CAS 인터페이스와 약간 다름

• expected가 swap 실패 시 최근 값으로 업데이트

– std::atomic<T>

(예제 코드를 이해하려면 알아야 합니다)

Lock Free Stack 구현

Stack

• 연결 리스트를 사용한 구현

stack::push()

Step 1 Step 2

A B N

Head

Step 3

N A

Head

B

B

N

Head

A

node 생성

next 설정

head 변경

stack::push()

Step 2 + race condition

A B

N

Head

X

문제점 Head가 중간에 바뀌는 문제

해결 방법 : Compare and swap Head가 바뀌면 Step 2,3 재시도

stack::push()

• 구현 코드

– node 생성

– next 포인터를 현재 head로 설정

– head 포인터를 node로 설정

Race condition! 다른 쓰레드가 head 변경 가능

(1)

(2)

(3)

Lock-free stack::push()

• Lock-free 구현

– head가 외부에서 변경되면, 실패 후 재시도

(1)

(2)

(3)

stack::pop()

Step 1 Step 2

B A

Head

B

Head

A

node node

(메모리 해제 필요)

Lock-free stack::pop()

• 구현 방식

– head 읽기

– head를 headnext로 설정

–획득한 node에서 값 가져오기

stack::pop() 문제점 #1

• 문제점 1

–스택이 비어있을 때 NULL 참조

–저장 값을 포인터로 바꾸고, old_head 체크

stack::pop() 문제점 #2

• 문제점 2

–메모리 누수 (node를 delete 하지 않음)

– delete 되면, 메모리 참조 및 ABA 문제 발생

–메모리 해제(reclaim) 기법 필요

해제 된 메모리 참조 위험

다른 쓰레드에서 delete 되면?

ABA 문제

B

Head

A

C

Head

A

(1) pop 중인 쓰레드 : 주소 A와 B 확인, head.CAS( A B ) 함수 호출

(2) 다른 쓰레드 : A, B pop, 이후 A push

(시간)

C

Head

A

(3) pop 중인 쓰레드 : head.CAS( A B ) 함수 실행

주소 A를 delete 한 이후, new로 A를 다시 할당 가능

B??

메모리 해제 기법 #1

• 홀로 실행 중인 쓰레드가 몰아서 메모리 해제

실행 중인 쓰레드 수

쓰레드 수 체크 및 메모리 해제

한번에 몰아서 해제

• 개념적인 처리 과정

한번에 몰아서 해제

• 개념적인 처리 과정 (개선 된 버전)

한번에 몰아서 해제

• 구현 코드

리스트에 병합하도록 구현된다

Null로 만들지만, 다른 쓰레드에서 추가 될 수 있으므로

더 자세한 코드는 책에...

한번에 몰아서 해제

• 단점

– Starvation 발생 가능

–삭제 대기 중인 노드가 무한정 늘어날 수 있다.

메모리 해제 기법 #2

• 해저드 포인터

–잠재적인 위험을 가진 포인터를 관리하는 기법

– 에서 특허 소유

해저드 포인터 설정

Pop (CAS)

해저드 포인터 해제

해저드 포인터 체크

지금 삭제 나중에 삭제

테이블 체크 & 삭제

Step 1 Step 2

해저드 포인터

• 해저드 포인터 설정 방식

해저드 포인터(hp)에 들어간 시점에서 지워지지 않았음을 보장하기 위한 반복문

해저드 포인터

• 해저드 포인터를 이용한 stack::pop() 구현

해저드 포인터로 관리되는 부분

해저드 포인터

• 해저드 포인터를 이용한 stack::pop() 구현

해저드 포인터

• 장점

– Starvation이 생기지 않음

• 단점

–포인터 관리를 위해 테이블(추가 메모리) 사용

–포인터 관리를 위한 테이블 개수의 한계

–특허가 있다.

메모리 해제 기법 #3

• Reference counting

– std::shared_ptr<T> :

• 스마트 포인터 : 메모리 해제를 알아서 해 준다.

shared_ptr 사용한 lock-free stack

안타깝게도 책에 있는 코드는 FAIL

Double-word CAS

• std::shared_ptr<T>는 double-word

– double-word CAS를 지원하면 해결됨

–다행히 최신 CPU 에서는 128-bit CAS 지원

• CPU에서 지원되지 않으면?

–대안 : 포인터 압축 테크닉

– 64bit 포인터의 일부를 다른 목적으로 사용

Double-word CAS

• 컴파일러의 std::atomic<T> 지원 여부

– is_lock_free()는 128bit 에서 false

– std::shared_ptr<T> 는 not trivially copyable 라서 atomic에 사용불가

• 컴파일러에서 지원되지 않으면?

–대안 : 전부 직접 구현

128-bit CAS 구현

• CMPXCHG16B

128-bit CAS 구현

• VC++ : _InterlockedCompareExchange128()

128-bit CAS 구현

cmpxchg16b

Reference counting 직접 구현

Split Reference Count

• external count

– 초기값 : 0

– pop 시도 전 증가

• internal count

– 초기값 : 0

– pop 시도 후 1 감소

node

data

internal_count

counted_node_ptr

counted_node_ptr

external_count

ptr

Split Reference Count

• pop() 성공 후

– Pop을 성공한 쓰레드는,

–최종 external_count 확보,

– internal_count 에 atomic_add

• pop() 시도 이후 (성공 / 실패 여부 상관없이)

– internal_count 감소 후 0면 노드 삭제

external_count +1 external_count +1

pop 성공 pop 실패

internal_count -1

internal_count +n

Split Reference Count

• 삭제 지연 Case 1

< 0

= 0, delete

external_count +1 external_count +1

pop 성공 pop 실패

internal_count +n

internal_count -1

Split Reference Count

• 삭제 지연 Case 2

= 0, delete

> 0

Lock Free Stack 정리

• CAS를 이용해서 구현

• 어려운 부분 : 메모리 해제 문제

–몰아서 해제

–해저드 포인터

–레퍼런스 카운팅

• Double-word CAS 지원 문제

• std::shared_ptr의 atomic 지원 문제

• 직접 구현

기타 최적화

• Atomic operation 메모리 모델 최적화

–아직까지는 Memory model 고려하지 않음

–기본 값 : memory_order_seq_cst 적절한 메모리 모델로 변경

• _InterlockedCompareExchange128

–메모리 모델 설정 불가

Lock Free Queue 구현

Queue

• 연결 리스트를 사용한 구현

개념 이해를 위한 코드 (원래는 shared_ptr를 씁니다)

Queue

• 연결 리스트를 사용한 구현

D C

Tail

B

Head

A

pop push

Queue 의 구분

• Single-producer (SP)

• Multi-producer (MP)

• Single-consumer (SC)

• Multi-consumer (MC)

• 제약 조건에 따라서

– SPSC, SPMC, MPSC, MPMC 로 구분

이건 쉬움

Queue

• Thread를 고려하지 않은 단순 구현

비어 있을 때의 예외 처리 하나 있을 때도 예외 처리

이미 뭔가 복잡하다? 이 방식을 lock-free로 구현하기는 힘듦..

더미 노드를 사용하는 Queue

– Head와 Tail은 항상 null이 아님

– pop(), push() 구현이 단순하게 구현 가능

/ C

Tail

B

Head

A

더미 노드를 사용하는 Queue

• push()

–더미 노드에 데이터만 추가 실제 node가 됨

–이후 새로운 더미 노드 생성

Head를 수정 할 필요가 없음

/ C

Tail

B

Head

A

더미 노드를 사용하는 Queue

• pop()

– Head != Tail 인 경우에만 pop()

– Tail을 수정 할 필요가 없음

/ C

Tail

B

Head

A

SPSC Lock-free Queue

• Push 구현

– tail 포인터를 atomic 으로 구현

– (1)에서 더미 노드에 데이터 넣음

(1)

(2)

SPSC Lock-free Queue

• Pop 구현

– (3) 이 (2)와 synchronized-with 관계

–여기까지는 SPSC에서 완벽하게 동작

(3)

Multi-consumer pop()

• Head != Tail 체크 후 CAS로 pop 시도 반복

• 메모리 해제 문제

–스택 구현에서 이미 다룬 문제

(책에서는 스택에서 구현했던 Split Reference Count로 진행)

Multi-producer push()

/

Tail

B

Head

A

C

/ C

Tail

B

Head

A

1) CAS로 데이터만 추가

2) 새 노드 추가

3) Tail 변경

문제점 #1 : CAS 시점의 tail

• CAS 시점에 Tail이 해제 되었을 수 있다.

이 시점에서 다른 쓰레드에서 Push Pop 일어나면 old_tail 사라질 수 있음

Split Reference Count

• tail 도 counted_node_ptr로 구현

• counted_node_ptr의 개수를 체크

/ C

Tail

B

Head

A

Split Reference Count

• external count

– 최대 2개

• internal count

• external_counters

counted_node_ptr

external_count

ptr

counted_node_ptr

external_count

ptr

node

data

internal_count

counted_node_ptr

ext_counters = 2

문제점 #2 : Blocking

• Push 첫번째 단계...

–한 쓰레드가 데이터만 추가 한 상태에서,

–다른 쓰레드에서 첫번째 단계만 반복하면?

• 계속 실패, lock-free가 아니게 됨

D C

Tail

B

Head

A

Blocking 문제 해결

• Tail node에 데이터가 추가 되었을 때

–모든 쓰레드가 새 dummy node 추가 시도

–모든 쓰레드가 새 노드로 Tail 변경 시도

책을 보시면 됩니다. (어려우니 각오하시고..)

Lock Free Queue 정리

• CAS를 이용해서 구현

• 어려운 부분 : 메모리 해제 문제

–스택과 근본적으로는 같은 문제

– Tail 처리 구현 중 추가로 파생되는 문제

• ABA 문제

• Blocking ( busy-waiting ) 문제

총정리

• Lock-free 자료구조 작성 요령

–처음부터 메모리 모델을 고려하지는 않음

• 이미 충분히 어려우니까

– Lock-free 메모리 해제 기법들 적용

• stack 구현에서 제시한 세 가지 기법

– ABA 문제에 대한 고려 필요

– Busy-waiting을 인지하고 다른 쓰레드 일 해주기

• 누가 시작했던 멈추면 안됨

결론

• 직접 구현하기는 어렵다

• 대안

–잘 구현된 구현체 ( boost, intel TBB 등 ) 사용

– MPSC 형태 처럼 제약을 주는 형태로 구현

– Java나 C# 사용...