들어가며
개인 프로젝트를 하면서 다음과 같은 기능이 필요한 Pub-Sub모델에 필요한 큐를 만들어야 했습니다.
- 동시성 제어를 할 수 있어야 한다.
- 사용할 엔티티가 없다면 CPU 사용율을 낮추기 위해 wait 상태에 빠져야한다.
이 두가지 조건을 만족하는 큐를 개발하면서 공부했던것을 적어보려고 합니다. 이름이 왜 BlockingQueue 개발기냐면은… 이런 기능을 제공하는 큐가 있는줄 몰랐고(당시에) 오늘 이펙티브 자바를 공부하다가 BlockingQueue를 알게 되었고 레퍼런스를 찾아본 결과 C#에도 BlockingQueue가 있다는 것을 알게 되었기 때문입니다.. 레퍼런스를 꼼꼼히 찾아보는 습관도 많이 길러야겠다.. 바보같은..
부족한 부분에 대한 지적은 감사히 받겠습니다!
wait / pulse와 AutoResetEvent
c#에서 스레드를 wait 상태에 들어가게 하고 깨우는 방법에는 pulse와 AutoResetEvent의 set이 있습니다. 이 둘의 큰 차이점은 pulse는 깨어날 스레드가 없다면 pulse 신호는 소멸한다는 점입니다. 이와 반대로 AutoResetEvent의 set은 깨어날 스레드가 없다면 pulse 신호가 있었다는 점을 기억한다는 점입니다. 그렇기 때문에 BlockingQueue를 개발하는데 필요한 기능으로 pulse를 사용했습니다. AutoResetEvent를 사용하게 된다면 다음과 같은 문제가 생기게 됩니다.
- 큐의 엔티티는 현재 n개이다.
- Producer가 엔티티를 삽입하며 pulse 신호를 보낸다.
- 이 때 큐의 엔티티가 현재 존재하기 때문에 wait상태에 들어가 있는 큐는 없다. 그렇기 때문에 이 pulse 신호는 기억 된다.
- Consumer가 큐의 엔티티를 다 처리하고 엔티티가 존재하지 않기 때문에 wait 상태에 들어간다.
- 이때 기억된 pulse 신호를 받고 깨어난다.
- 하지만 엔티티는 현재 0개이기 때문에 오류가 발생한다.
위와 같은 상황을 방지하기 위해 pulse를 선택했습니다. pulseAll을 선택할 수도 있었지만 당시에는 consumer 1개 producer 1개 이기 때문에 pulse를 선택했습니다. 다시 개발한다면 pulseAll을 선택할 생각입니다.(확정성 고려)
wait은 다음과 같이 동작합니다.
- 락을 내려 놓고
- 락 오브젝트에 해당하는 큐에 들어가 wait상태에 빠진다.
그렇기 때문에 pulse 신호를 보내면 오브젝트 내에 있는 큐에서 순차적으로 깨어나게 됩니다.
동시성 제어
동시성 제어에는 스핀락을 사용했습니다. 나중에 C#의 Concurrent Collections의 소스코드를 까보니까 얘네들은 Interlock으로 구현을 했음을 확인할 수 있었습니다.(이것도 당시에 이런 컬렉션이 있는줄 몰랐음.. 솔직히 말하면 C++에서 STL이 thread-unsafe한 컨테이너들만 있고 boost 라이브러리에 동시성 관련한 컨테이너들이 있는줄 알고있었기 때문에 당연히 동시성 제어하려면 직접 구현해야 하는 줄 알았다..)
우선적으로 말하자면 스핀락과 인터락은 둘 다 커널모드 동기화를 지원하지 않는 락입니다. MSDN에 따르면 Interlock이 성능을 크게 낮추는 경우에나 SpinLock을 사용하라고 권장합니다.
SpinLock 구조는 잠금을 획득하기 위해 대기하는 동안 회전하는 하위 수준의 동기화 기본 형식입니다. 멀티 코어 컴퓨터에서 대기 시간이 짧은 것으로 예상되고 경합이 최소인 경우 SpinLock의 성능이 다른 종류의 잠금보다 더 뛰어납니다. 그러나 프로파일링을 통해 System.Threading.Monitor 메서드 또는 Interlocked 메서드가 프로그램의 성능을 크게 낮추는 것을 확인하는 경우에만 SpinLock을 사용하는 것이 좋습니다.
근데… 이거로는 부족했습니다. 둘다 같은 기능을 제공하는것 처럼 보이는데 InterLock과 SpinLock을 구분지어둔 이유가 있을꺼라고 생각했습니다. 결국 찾다 찾다 다음과 같은 내용을 알 수 있었습니다.(2006년 글입니다.)
The difference between the two is IRQL that the spinlock raises to when
it is acquired. If you acquire the spin lock on your own, it is
DISPATCH_LEVEL, if you use the interlocked versions it is the highest
possible IRQL on the machine. So, you can’t intermix them b/c you could
deadlock if you acquired the spinlock, raised to dispatch level and then
your interrupt fired on the same thread and you tried to acquire the
lock using the interlocked function.
읽어 보자면 스핀락과 인터락의 IRQL(Interrupt Request Level)이 다르다는 말입니다. 스핀락은 DISPATCH_LEVEL에서 락을 수행하고 인터락은 이보다 높은 레벨에서 락을 수행한다고 합니다. 그래서 혼용해서 쓰면 안되며 혼용해서 쓰다가는 데드락에 걸린다고 합니다.
즉 이 내용에서 얻을 수 있는건 두가지 입니다.
- 두 락의 수행 레벨이 다르다.(내가 알고싶었던거)
- 두 락을 혼용해서 쓰면 데드락에 빠진다.(락을 수행하는 레벨이 다르니)
알게된 내용을 뒤로하고 커널모드 동기화가 필요없어야 했던 이유 즉, synchronized나 semaphore, mutex가 필요없어야 했던 이유는 큐에서 작업을 꺼내오는 일이 매우 짧다는 이유에 있었습니다. 매우 짧아서 충분히 기다릴 수 있으며 이 과정에서 커널모드 동기화까지 가며 시간을 잡아먹을 필요가 없어보였습니다. 락에 대한 내용은 여기서 확인할 수 있습니다.
결론
BlockingQueue를 만들면서 많은것을 배울 수 있었습니다. 첫째로 Lock에 대한 명확한 개념(커널모드 동기화, 유저모드 동기화). 둘째로 pulse 신호를 기억하는 메소드도 존재한다는 점. 마지막으로.. 레퍼런스를 꼼꼼히 찾아보고 진짜 없으면 개발해야한다는 점…
참조
https://docs.microsoft.com/ko-kr/dotnet/standard/threading/spinlock
https://community.osr.com/discussion/94870/difference-between-interlocked-and-spin-lock-version-of-list-management-functions