thread structure를 safe하게 만들어주기 위해서 lock을 data structure에 추가한다.
이 data structure는 multiple thread가 접근할 수 있는 data structure이다.
lock이 어떻게 add되었냐가 data structure의 correctness와 performance를 결정한다.
shared variable에 접근하는 code segment가 critical section이 된다.
Single lock을 추가한다.
data structure를 manipulate하는 routine을 call할 때 lock이 acquire된다.
각 thread는 single shared counter를 update할 때
variable에 access할 때마다 lock을 acquire하기 때문에 performance가 guarantee되지 않고 오히려 점점 worse해진다.
thread의 수가 늘어날수록 counting operation을 끝내기 위한 시간이 길어진다. 많은 thread가 lock variable을 competing하기 때문이다.
한 thread가 lock을 acquire하면 그 variable을 사용하고자 하는 남은 모든 thread는 block된다.
이런걸 giant lock variable이라고 한다.
impact가 크다는 것임. 크기가 크다는 것이 아니라.
Synchronized counter scales poorly.
더 많은 작업이 수행되더라도 병렬적으로 처리된다.
task를 완료하는 데에 드는 시간은 증가하지 않는다.
sloppy는 lazy, loose하다는 뜻이다.
sloppy counter는
을 통해 표현된다.
예를 들면, CPU가 4개인 컴퓨터에 대해 4 개의 local counter, 1 개의 global counter를 갖는다.
Sloppy counter는 shared variable에 접근할 때 항상 lock을 획득하는 것이 아니다.
Shared variable에 접근할 때마다 giant lock을 획득하는 것이 아니라 global variable의 local copy를 가지고 그 local counter로 작업을 한다. 그렇게 하면 자신의 local counter를 사용하는거라 다른 thread와 경쟁할 필요가 없다!
하지만 local counter만 사용한다면 global counter를 maintain하는 의미가 없다.
누군가가 counter의 정확한 값을 읽기를 바란다면 그 사람은 global counter의 값을 읽어야 한다.
Local counter를 관리하다가 주기적으로 global counter의 값을 update하고 그 때만 global lock을 acquire하는 것이다.
그러면 global lock을 획득하는 횟수가 줄어들면서 performance 문제를 해결할 수 있다.
Core에서 running하는 thread는 counter를 update하고 싶어한다.
이 때 sloppy counter에서는 자신의 local counter를 증가시킨다.
각 CPU는 자신의 own local counter를 가지고 있다.
CPU의 각 thread는 자신의 local counter를 contention없이 update할 수 있다! local counter에 access하는 것이기 때문에 waiting하지 않는다는 것이다.
그러므로 (local) counter update는 scalable하다.
그리고 각 local value는 periodically하게 global counter로 transfer된다.
Global lock을 획득하고 global counter의 값을 local counter의 값만큼 증가시킨 뒤 local counter를 0으로 리셋시킨다.
얼마나 자주 local-to-global transfer가 발생하는지는 threshold S
(sloppiness)가 결정한다.
S
가 작을 수록 counter는 non-scalable counter처럼 동작한다.
S
가 클 수록 counter는 scalable해지고 global counter의 값이 실제 count의 값과 달라진다.
Sloppy counter에서 performance와 accuracy 사이에는 trade-off이 존재한다.
Global counter의 값을 매우 정확하게 유지하려면 local counter의 값을 update할 때마다 global counter의 값을 update 해야하고 그것이 performance를 저하시킨다.
하지만 sloppiness를 높게 만들면 accuracy가 감소한다. 바뀐 값이 global counter에 낮은 주기로 반영되기 때문이다.
Threshold S
는 5로 set 되어있다.
4개의 CPU에 각각 thread가 존재한다.
각 thread는 자신의 local counter L1
, ..., L4
를 update한다.
각 local counter에서 S
에 도달할 때마다 lock을 획득해 global counter에 값을 반영하고 있다.
매우 작은 sloppiness를 사용하면 global counter가 매우 정확하지만 global lock을 acquire하고 release하는 빈도가 증가하면서 performance가 매우 낮아진다.
Low performance -> accurate counter
High performance -> inaccurate counter
각 4개의 thread가 counter의 값을 4개의 CPU에 대해 1 million times 증가시킬 때
Core의 local counter에 대해 lock을 획득한 후 counter의 값을 증가시키지만 성능에는 영향을 미치지 않는다.
하지만 global lock을 획득할 때에는 같은 상황에 직면한 다른 thread들과 경쟁하게 된다. 이 때 성능저하가 발생한다.
Global counter의 값은 정확한 값이 아닐 수 있다. global counter를 항상 update하는 것이 아니기 때문이다.
Concurrent linked list는 여러 thred가 동시에 linked list에 접근할 수 있도록 하는 것이 목표이다.
Concurrent linked list에 새로운 node를 insert할 때에는 linked list 전체를 protect하는 global lock을 획득해야한다.
위의 코드는 insert의 entry부터 lock을 acquire했지만 malloc()
이 실패할 경우 insert에 fail하기 전에 lock을 release 해야하므로 비효율적이다.
이 때 lock을 획득하려면 경쟁을 해야하므로 최대한 lock을 덜 잡기 위해 exception condition을 다 검사한 뒤에 lock을 획득하고, actual critical section만 lock으로 둘러싸야한다.
이에 따라 코드를 수정하면 다음과 같이 된다.
Scaling linked list는 hand-over-hand locking(lock coupling)을 사용한다.
Hand-over-hand locking은 hand shaking을 한다는 뜻으로, 전체 list에 대한 global lock을 사용하지 않고 각 node마다 lock varible을 갖는다.특정 node에 lock을 먼저 걸어서 key를 확인하고 내가 찾던 key가 아니면 다음 key를 확인하기 위해 다음 node에 lock을 건다.
그럼 두 개의 lock을 hold한 상태가 되는데 그 때 첫 번째 node의 lock을 release한다.
정리하자면 다음 node의 lock을 잡은 뒤 현재 node의 lock을 relese하는 것이다.
그래서 hand-over-hand이다. lock crabbing이라고 하기도 한다.
내가 항상 보고 있는 애의 lock을 holding하기 때문에 concurrency가 증가한다.
다른 node의 lock은 holding하지 않지만 linked list 구조이기 때문에 다른 thread들은 내가 lock을 holding하고 있는 node를 bypass할 수 없다.
실제로는 list를 traversal할 때 각 node의 lock을 acquire하고 release하는 overhead는 prohibitive하다.
이 queue에는 두 개의 lock이 있다.
이 두 lock의 목표는 enqueue와 dequeue operation에서 concurrency가 가능하게끔 하는 것이다.
queue에서 element가 추가되거나 제거되는 것은 head와 tail에서 뿐이고 아무도 queue의 중간은 접근할 수 없기 때문에 lock을 2개만 사용해도 된다.
enqueue를 할 때 원래 null이었던 q->tail->next
가 tmp
를 가리키게 하고 tail pointer가 새로운 node를 가리키게 한다.
📌 이 부분을 mutex를 제거하고 TAS
(test-and-set)으로 구현할 수 있다!
TAS(&tail, tmp);
를 쓰면 atomically하게 pointer(tail
)이 가리키고 있던 값을 tmp
로 바꿔주고, old_tail
을 return한다.
그럼 그 old_tail
을 tmp에 연결시켜 주면 됨!
정리하자면
// pthread_mutex_lock(&q->tailLock);
old_tail = TAS(&tail, my_node);
old_tail->next = my_node;
// pthread_mutex_unlock(&q->tailLock);
이렇게 된다.
만약 세 thread가 있다고 가정해보자.
세 thread가 동시에 TAS
만 실행하고 나면 tail이 가장 늦게 TAS
를 실행한 node를 가리키게 된다.
그리고 각 thread는 각자 자신이 생각하는 unique한 old_tail
을 가지고 있다. TAS
는 atomic하게 동작하기 때문에 각각의 old_tail
은 unique하다.
이것을 통해 implicit한 linked list structure가 만들어지고 두 번째 instruction을 통해 explicit으로 만들어준다.
Hash table은 resize할 수 없다.
Concurrent list를 사용해 build하고 list로 표현되는 각 hash bucket마다 lock을 가지고 있다.
즉, 각 bucket마다 concurrent linked list가 존재한다.
4개의 thread가 각각 10000~50000번의 update를 할 때 성능은 다음과 같다.
정말 많은 경우에 thread는 실행을 계속하기 전에 condition이 true인지 확인하고 싶어한다.
예를 들면, parent thread는 join()
을 호출했을 때 child thread가 complete되었는지 확인하고 싶어한다.
이 상황에서 parent는 어떻게 child thread를 기다리고 child는 어떻게 자신의 실행이 끝났다는 것을 parent에게 나타낼 수 있을까?
이 방법은 global variable인 done
이 flag로 쓰여서 이 값이 1이 될 때까지 parent가 spin한다.
이 방법은 parent가 spin하면서 CPU time을 낭비하기 때문에 굉장히 비효율적이다.
Condition variable을 사용하면 된다!
Condition variable은
Waiting on the condition
calling thread를 sleep하게 만든다.
실행의 어떤 상태가 자신이 원하는 것이 아닌 경우 thread가 자기 자신을 sleep 상태로 만들 수 있는 explicit queue가 있다.
Signaling on the condition
꿀잠자고 있던 thread를 wake up하는 것이다.
다른 thread가 state를 바꾸었을 때 꿀잠자며 기다리고 있던 thread를 깨워서 실행을 재개하도록 할 수 있다.
먼저 condition variable을 declare한다.
Pthread_cond_t c;
적절한 initialization이 필요하다.
POSIX
call에서의 operation은 다음과 같다.
pthread_cond_wait(pthread_cond_t *c, pthread_mutex_t *m); // wait()
pthread_cond_signal(pthread_cond_t *c); // signal()
wait()
은 parameter로 mutex를 필요로 한다.
wait()
은 lock을 release하고 자신을 call한 thread를 sleep 상태로 바꾼다.
만약 thread가 꿀잠자다가 일어날 경우, thread는 반드시 lock을 re-acquire 해야한다.
sleep할 때 lock을 잡고 sleep 했으니까 OS가 thread를 깨우면서 다시 lock을 잡아준다.
여기서 c
는 parent와 child가 share 하고있는 condition variable이다.
sleep할 때는 lock을 holding하고 있지 않아서 child가 critical section을 실행할 수 있고 wakeup할 때 다시 lock을 잡아준다.
main()
에서는 다음과 같은 실행 순서를 가진다.
done
위의 예제에서 만약 done
이 없었다고 생각해보자.
child가 즉시 실행돼서 thr_exit()
이 먼저 mutex m
을 잡으면 signal을 보내지만, condition c
를 가지고 잠을 자고 있는 thread는 아무도 없다.
그 이후에 parent가 실행되면 wait()
을 호출하게 된다.
하지만 parent를 깨워줄 thread가 없으므로 영원히 wake up 할 수 없다.
parent 영면...
위의 예제에서는 done
이 있기 때문에 thr_exit()
가 먼저 실행되면 thr_join()
에서는 꿀잠을 자지 않았다.
이 예제에서는 race condition이 일어날 수 있다.
parent가 thr_join()
을 call하면 parent는 done
의 값을 확인할 것이다. 그리고 done
이 0인 것을 보고 꿀잠을 자려고 할 것이다.
parent가 꿀잠을 자기 직전에 interrupt가 발생해서 child가 실행된다.
child는 state variable done
의 값을 1로 바꾸고 signal을 보낼 것이다.
하지만 현재 꿀잠자고 있는 thread가 없기 때문에 아무 thread도 깨어나지 않을 것이다.
이후 다시 interrupt가 발생해 parent가 실행되고 꿀잠을 자면, parent는 영면이다.
아무도 깨워줄 수 없다.
Producer
data item을 produce한다.
data item을 buffer에 넣고 싶어한다.
Consumer
data item을 버퍼에서 꺼내서 어떤 방식이든 consume하고 싶어한다.
multi-thread의 web server가 위의 경우에 해당한다.
producer은 work space에 HTTP request를 놓는다.
consumer은 queue에서 request를 빼내 그것들을 처리한다.
Bounded buffer는 한 프로그램의 output을 다른 프로그램으로 pipe하고 싶을 때 사용한다.
grep foo file.txt | wc -l
grep
process는 producer이다.
wc
process는 consumer이다.
그들 사이에는 in-kernel bounded buffer가 있다.
bounded buffer은 shared resource이기 때문에 synchronized access가 필요하다.
count
가 0일 때만 buffer에 data를 넣고 count
가 1일 때만 buffer에서 data를 가져온다.
ㅖroducer는 shared buffer에 loops
번 integer를 넣는다.
Consumer은 shared buffer에서 data를 가져온다.
if
Statement하나의 condition variable cond
를 사용하고 associated lock mutex
를 사용한다.
Pthread_cond_wait()
을 호출한다는 것은 spin waiting을 하지 않겠다는 뜻이다.
consumer가 깨어난다는 것은 data가 buffer에 있다는 뜻이다! 그런데 만약 나 말고 다른 애가 먼저 깨어나서 그 data를 사용해버린다면???
위의 코드는 single produer, single consumer일 때는 제대로 동작한다.
하지만 그 이상의 producer, consumer일 때는 제대로 동작하지 않는다.
누군가가 consumer를 깨웠을 때 그 consumer가 data를 grab할 수 있다는 보장이 없다.
꿀잠을 잘 때 mutex
를 놔버렸기 때문에 깨어나자마자 mutex
를 re-acquire해야 하는데 그 사이의 gap이 존재한다.
내가 꿀잠자고 일어나서 mutex
를 acquire하기 전에 다른 애가 그 data를 써버릴 수 있는 것이다.
그러면 꿀잠자고 일어나서 get()
을 호출했을 때 count
의 값이 0이어서 에러가 난다.
이 문제는 꿀잠자고 일어난 것과 lock을 다시 획득하는 것이 atomic하지 않아서 race condition이 발생하는 것이다.
내가 lock을 잡고 잠을 잤고 일어나서 lock을 다시 잡았다고 하더라도 그것은 내가 lock을 계속 잡고 있었던 것이 아니기 때문에 내가 잠깐 놨다가 다시 lock을 잡는 순간에 다른 thread가 개입할 여지가 있다.
이 문제는 queue를 producer와 consumer가 동시에 access하지 못하도록 만들어서 해결한다.
consumer1
이 running하다가 count
가 0이니까 get할 data가 없어서 꿀잠을 자기 시작했다.
그 이후 parent
가 실행되어 queue가 비어있는 것을 확인하고 put()
으로 data를 넣고 꿀잠자는 애들한테 signal을 보내서 consumer1
이 깨어났다.
그리고 parent
가 실행을 계속 하다가 count
가 1이 된 것을 확인하고 더 data를 넣고 싶은데 queue가 꽉 차서 더 이상 넣을 수가 없으니 꿀잠을 자기 시작했다.
그러면 Ready
상태에 있던 thread가 순차적으로 실행될 것이다.
먼저 consumer2
가 실행되면서 buffer에 있는 data를 get()
으로 가져갔다. 그리고 signal을 줘서 parent
가 깨어났다.
그런데 이 다음에 운나쁘게 parent
가 아닌 consumer1
이 실행되면 queue가 비어있는 상태에서 깨어났기 때문에 get()
을 할 data가 없다 (error)!
문제는 간단한 이유로 인해 발생했다.
consumer1
이 깨어난 이후 consumer1
이 실행되기 전에 bounded buffer의 state가 consumer2
에 의해 바뀌었기 때문이다.
깨어난 thread가 실행이 될 때 bounded buffer의 상태가 desired라는 보장이 없다. 이를 Mesa semantics 라고 한다.
내가 일어났는데 내가 일어나도 되는 상태가 아닌 것이다. lock을 항상 들고 있었던 것이 아니라 잠들 때 lock을 잠깐 놨었기 때문에 발생한다.
Hoare semantics는 thread가 깨어났으면 즉시 실행해도 괜찮다는 더 확실한 보장을 해준다.
야 너 일어났어? 그럼 네가 이거 가져가면 돼! 할 수 있도록 잠을 자도 lock을 끝까지 hold하는 것이다.
내가 내내 lock을 hold하고 있었기 때문에 일어나면 걱정 없이 직진할 수 있다.
consumer1
이 깨어나고도 shared variable의 상태를 다시 확인하는 것이다.
잠에서 깨어났는데 buffer가 비어있다면, 다시 자러가면 그만이다.
Condition variable에 대해 기억해야 할 간단한 rule은, 항상 while loop을 사용한다는 것이다!
하지만 이 code 또한 아직도 문제가 있다.
consumer1
이 while loop을 실행하며 조건을 한 번 더 확인한 이후 get()
으로 data를 가져갔다.
condition variable을 통해 꿀잠을 잘 때는, queue에 들어가는 것이기 때문에 잠이 든 순서대로 wake하게 된다.
현재 queue에는 consumer2
-> producer
순으로 잠들어있다.
consumer1
이 data를 grab하고 난 뒤, parent
를 깨우려고 signal을 보냈다. 그런데 queue에 들어간 순서 때문에 consumer2
가 일어나는 불상사가 발생했다.
그래서 consumer2
는 일어났는데 grab할 data가 없으니 나중에 자기 순서가 오면 다시 잠들어버릴 것이다.
consumer1
이 실행을 계속하다가 count
가 0이 되었으므로 꿀잠을 자기 시작한다.
consumer1
이 잠들었으므로 유일하게 Ready
상태였던 consumer2
가 일어났는데, queue가 비어있으므로 다시 잠들어버렸다.
그러면 모든 producer와 consumer가 잠들었지만 아무도 깨울 수 없다.
이 문제가 발생한 원인은 producer와 consumer가 same condition variable을 share하는 것이다.
모든 애들이 같은 condition variable을 사용하니까 깨우고 싶은 상대를 제대로 깨우지 못하는 것이다.
내가 누군가를 지정해서 깨우기 위해서는 condition variable을 다르게 사용해야한다.
즉, 이 상태를 해결하기 위해서는 same condition variable을 share하지 못하도록 만들어야 한다.
두 개의 condition variable과 while문을 사용하면 된다!
producer thread는 condition empty
에서 wait하고, fill
에 signal을 보낸다.
consumer thread는 condition fill
에서 wait하고, empty
에 signal을 보낸다.