
멀티쓰레드 프로그래밍에서 락은 많은 부분을 차지할 정도로 매우 중요하기 때문에,
락의 동작을 이해하는 것이 중요하다.
멀티쓰레드 프로그래밍에서 사용되는 락의 종류
- SpinLock (무한 대기 & 체크)
- Context Switching (양보 및 쉬면서 대기)
- AutoResetEvent / ManualResetEvent (운영체제에 부탁 후 쉬면서 대기)
: 스레드가 락을 획득할 수 있을 때까지 반복적으로 락의 상태를 확인하면서 '회전(spin)'하는 것.
바쁜 대기(Busy-Wait)
: SpinLock은 바쁜 대기 방식을 사용한다. 스레드가 락을 얻기 위해 반복적으로 락의 상태를 확인하며, 이 과정에서 CPU 사이클을 소비한다.
오버헤드 감소
: 전통적인 락 메커니즘은 락을 획득할 수 없을 때 스레드를 대기 상태로 전환시키고, 나중에 다시 깨워서 실행 상태로 전환한다. 이런 상태 전환에는 상당한 오버헤드가 발생한다. 반면, SpinLock은 이러한 상태 전환을 피하므로, 락 대기 시간이 매우 짧은 경우에는 오버헤드가 적다.
단기 대기에 적합
: SpinLock은 락이 금방 해제될 것으로 예상될 때 유용하다. 즉, 락이 짧은 시간 동안만 필요한 경우에는 SpinLock이 효과적일 수 있습니다.
CPU 자원 소모
: SpinLock은 대기하는 동안 CPU 자원을 계속 소모한다. 락 대기 시간이 길어질 경우 CPU 자원 낭비가 심해질 수 있다.
스레드 우선순위와 교착 상태 주의
: SpinLock은 스레드 우선순위와 교착 상태(deadlock)에 민감하다. 우선순위가 낮은 스레드가 SpinLock을 기다리는 동안 우선순위가 높은 스레드가 CPU를 점유하고 있으면, 낮은 우선순위의 스레드는 락을 획득하기 어려울 수 있다.
namespace ServerCore
{
class SpinLock
{
volatile bool _locked = false; //잠금 해제 상태 (가시성 보장)
public void Acquire() //Enter
{
while (_locked)
{
//잠금 해제되기를 기다림
}
//내꺼 선언(소유권 획득)
_locked = true;
}
public void Release() //Exit
{
_locked = false;
}
}
internal class Program
{
static int _num = 0;
static SpinLock _lock = new SpinLock();
static void Thread_1()
{
for (int i = 0; i < 100000; i++)
{
_lock.Acquire(); //잠금(소유권 획득)
_num++;
_lock.Release(); //잠금해제
}
}
static void Thread_2()
{
for (int i = 0; i < 100000; i++)
{
_lock.Acquire(); //잠금(소유권 획득)
_num--;
_lock.Release(); //잠금해제
}
}
static void Main(string[] args)
{
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(_num);
}
}
}
ㄴ 결과값: -8367
-> 즉, SpinLock이 제대로 구현되지 않았다...! ('0'이 나왔어야 함)

: Acquire()에서 _locked 변수의 상태를 확인하고 설정하는 과정이 원자적(atomic)이지 않다.
즉, 한 스레드가 _locked가 false인 것을 확인한 후에 true로 설정하려고 할 때, 다른 스레드가 동시에 접근하여 _locked를 확인하고 수정할 수 있다. 이 경우, 두 스레드가 동시에 락을 획득한 것으로 잘못 판단하고 동시에 임계 구역에 접근할 수 있다.
💡 Interlocked.Exchange()로 원자성 해결

- 원자적 연산
: Interlocked.Exchange()는 _locked 변수의 값을 원자적으로 변경한다.
이 메서드는 _locked 변수의 현재 값을 읽고, 새 값을 (1)로 설정하는 동작을 단일 연산으로 수행함.
이는 여러 스레드가 동시에 _locked 변수에 접근하더라도, 언제나 한 순간에 하나의 스레드만이 변수의 값을 변경할 수 있음을 의미한다.
- 상태 확인과 변경의 결합
: Interlocked.Exchange()는 _locked 변수의 원래 값(original)을 반환하면서 새 값(1)을 설정한다. 이는 _locked 변수가 이미 잠겨있는지(1인지)를 확인하고, 잠금 상태로 변경하는 두 단계를 하나의 원자적 연산으로 결합한다.
Acquire()
: 스레드가 Acquire 메서드를 호출하면, _locked 변수에 대해 Interlocked.Exchange()를 사용하여 원자적으로 값을 1로 변경. 이전 값이 0이었다면 (잠금 해제 상태), original == 0 조건이 참이 되어 while 루프를 탈출하고, 스레드는 잠금을 획득. 만약 이전 값이 1이었다면 (다른 스레드가 이미 잠금을 획득했다면), while 루프는 계속 실행되어 스레드는 잠금이 해제될 때까지 기다린다.
Release(): 스레드가 작업을 마치고 Release 메서드를 호출하면, _locked 변수의 값을 0으로 설정하여 다른 스레드가 잠금을 획득할 수 있도록 한다.
💡 Interlocked.CompareExchange()
CAS 연산은 세 개의 주요 파라미터를 사용합니다:
1. 대상 메모리 위치(Target Memory Location): 값을 비교하고 변경할 메모리 위치입니다.
2. 새 값(desired): 예상 값과 메모리 위치의 값이 일치할 경우, 메모리 위치에 저장될 새로운 값입니다.
3. 예상 값(Expected Value): 대상 메모리 위치에 저장된 값이 이 값과 일치하는지 비교합니다.
ㄴ 하지만 이렇게 작성하면 가독성이 떨어진다.

즉, SpinLock은 Compare-And-Swap 연산을 하여 구현하는 Lock이다.

- Thread.Sleep(1)
- Thread.Sleep(0)
- Thread.Yield( )
이들은 락 구현 또는 스핀락 구현에서 성능을 최적화하거나 데드락을 방지하기 위해 사용될 수 있다.
: 현재 스레드를 최소 1 밀리초 동안 대기.
이 메서드는 운영 체제에게 현재 스레드를 지정된 시간 동안 중단하고 다른 스레드에게 실행을 양보하도록 지시한다.
Sleep(1)은 최소한 1 밀리초 동안 대기할 것이지만, 실제 대기 시간은 운영 체제의 스케줄링 정책과 시스템 부하에 따라 더 길어질 수 있다.
락 구현에서는 보통 더 긴 대기 시간이 필요할 때 사용.
: 현재 스레드를 즉시 중단하고, 운영 체제에게 다른 스레드에게 실행 기회를 제공하도록 요청한다.
Sleep(0)은 대기 시간을 명시적으로 지정하지 않으므로, 다른 스레드가 준비 상태에 있고 현재 스레드보다 우선 순위가 같거나 높다면, 운영 체제는 즉시 다른 스레드를 실행할 수 있다.
그러나 준비 상태에 있는 다른 스레드가 없거나 모두 현재 스레드보다 낮은 우선 순위를 가진 경우, 현재 스레드는 거의 바로 다시 실행을 계속할 수 있다.
락 구현에서는 더 짧은 대기 시간이나 빠른 응답이 필요할 때 사용될 수 있다.
: 현재 스레드가 실행을 중단하고 운영 체제에게 현재 프로세서의 나머지 시간을 다른 스레드에게 양보하도록 요청.
이 메서드는 현재 스레드를 즉시 대기 상태로 전환하지만, 다른 스레드가 즉시 실행될 준비가 되어 있지 않으면, 현재 스레드가 거의 바로 다시 실행될 수 있다.
Thread.Yield()는 특히 멀티코어 또는 멀티프로세서 시스템에서 유용할 수 있으며, 현재 스레드와 우선 순위가 비슷한 다른 스레드에게 CPU 시간을 빠르게 양보하고자 할 때 사용.
💡 사용 시 고려 사항
Thread.Sleep(0)과 Thread.Yield()는 둘 다 현재 스레드에 대한 실행을 양보하려고 할 때 사용되지만, 운영 체제의 스레드 스케줄링에 따라 그 효과는 다를 수 있다.
Thread.Sleep(1)은 더 긴 대기 시간을 유발하기 때문에, 스핀락과 같은 빠른 응답이 필요한 상황에서는 성능 저하를 일으킬 수 있다.
이러한 메서드들은 특히 락을 오래 보유해야 하는 상황이나 자원 경합이 심한 상황에서 유용할 수 있다. 그러나 이들을 사용할 때는 성능 저하를 고려해야 하며, 불필요한 사용은 피해야한다.
Context Switching에 대한 고려가 필요하다.
: 운영 체제의 멀티태스킹 기능과 밀접하게 연관된 개념.
컨텍스트 스위칭은 CPU가 하나의 프로세스(또는 스레드)에서 다른 프로세스(또는 스레드)로 작업을 전환할 때 발생하는 과정을 말한다.
이 과정에서 시스템은 현재 실행 중인 작업의 상태(컨텍스트)를 저장하고, 이전에 중단된 다른 작업의 상태를 복원합니다.
: 특정 시점에서 프로세스 또는 스레드의 상태 정보를 의미한다.
중단
: 운영 체제는 현재 실행 중인 프로세스나 스레드를 중단.
이는 종종 타이머 인터럽트, I/O 요청 완료, 시스템 콜 등에 의해 발생.
상태 저장
: 중단된 프로세스 또는 스레드의 현재 상태(컨텍스트)를 저장.
이 상태는 나중에 작업을 재개할 때 필요하다.
스케줄링
: CPU 스케줄러는 다음에 실행할 프로세스 또는 스레드를 결정.
상태 복원
: 새로 선택된 프로세스 또는 스레드의 상태를 복원.
이전에 저장된 컨텍스트 정보를 사용하여 작업을 계속한다.
실행 재개
: 새 프로세스 또는 스레드의 실행이 시작.
컨텍스트 스위칭은 필수적이지만, 일정한 오버헤드를 발생시킨다.
상태를 저장하고 복원하는 과정은 CPU 사이클을 소모하며,
이로 인해 시스템의 전체적인 성능에 영향을 줄 수 있다.
특히, 과도한 컨텍스트 스위칭(컨텍스트 스위칭 스톰)은 시스템의 성능 저하를 초래할 수 있다.
컨텍스트 스위칭의 비용을 줄이기 위해, 운영 체제는 효율적인 스케줄링 알고리즘을 사용하며, 때로는 프로세스와 스레드의 우선 순위를 조정하여 컨텍스트 스위칭을 최소화한다.
또한, 현대의 운영 체제는 종종 멀티코어 프로세서의 이점을 활용하여 병렬 처리를 통해 성능을 향상시키려고 한다.
AutoResetEvent는 이 이벤트를 기다리는 쓰레드들에게 신호를 보내 하나의 쓰레드만 통과시키고 나머지 쓰레드들은 다음 신호를 기다리게 한다.
이는 흡사 유료 주차장 자동 게이트와 같이 한 차량이 통과하면 자동으로 게이트가 닫히는 것과 같다.
ㄴ Lock이 구현되지 않고 무한 실행에 빠진 것이 아니라, 커널 영역까지 가서 코드를 실행하느라 속도가 현저히 느려진 것!
ㄴ for문을 10000번으로 줄이고 실행하면 결과가 나오는 것을 확인할 수 있다
하나의 쓰레드만 통과시키고 닫는 AutoResetEvent와 달리, 한번 열리면 대기중이던 모든 쓰레드를 실행하게 하고 코드에서 수동으로 Reset()을 호출하여 문을 닫고 이후 도착한 쓰레드들을 다시 대기토록 한다
ㄴ 즉, Lock 구현에 있어서 AutoResetEvent가 더욱 적합하다는 것을 알 수 있다. (문을 열고 닫는 것이 한 번에 실행되어야 하기 때문)
💡 이처럼 event의 경우에는 커널 영역 즉 운영체제에게 요청하는 것이기 때문에 큰 부담이 된다는 것을 항상 주의해야 한다!
커널을 이용하여 순서를 맞추는 또다른 방법
ㄴ 다만, 기존에 사용하던 SpinLock에 비해 다소 느리다. Mutex 락킹은 Monitor 락킹보다 약 50배 정도 느리기 때문에 한 프로세스내에서만 배타적 Lock이 필요한 경우는 C#의 lock이나 Monitor 클래스를 사용한다.
📄참고자료
[인프런] c#과 유니티로 만드는 MMORPG 게임 개발 시리즈_4. 게임 서버
AutoResetEvent
ManualResetEvent
Mutex