이전 포스트에서 DeadLock에 대해 한번 언급을 했었습니다.
DeadLock은 멀티 스레드 환경에서
스레드가 자원을 계속 점유하여 다른 스레드가 이 자원을 사용하기 위해 대기하는 문제를 말합니다.
이번 포스트에서는 멀티 스레드 환경에서 DeadLock이 발생하는 예시를 한번 살펴보고
DeadLock을 어떻게 해결해야 하는지 한번 고민해보도록 하겠습니다.
1) 예시 코드
class SessionManager
{
static object _key = new object();
public static void Test()
{
lock (_key)
{
ClientManager.TestClient();
}
}
public static void TestSession()
{
lock(_key)
{
}
}
}
class ClientManager
{
static object _key = new object();
public static void Test()
{
lock (_key)
{
SessionManager.TestSession();
}
}
public static void TestClient()
{
lock (_key)
{
}
}
}
class Program
{
static int number = 0;
static void Thread_1()
{
for (int i = 0; i < 10000; i++)
{
SessionManager.Test();
}
}
static void Thread_2()
{
for (int i = 0; i < 10000; i++)
{
ClientManager.Test();
}
}
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(number);
}
}
위와 같은 코드가 있습니다.
코드에서는 SessionManager와 ClientManager의 2개의 클래스가 존재합니다.
각각 TestSession 함수와 TestClient 함수가 존재하는데,
lock을 사용하여 임계구역을 설정하고 있습니다.
그리고 두 클래스 모두 Test 함수를 갖고 있는데,
SessionManager의 Test 함수는 임계구역을 설정하여 ClientManager의 TestClient 함수를 실행하고 있고,
ClientManager의 Test 함수도 임계구역을 설정하지만 SessionManager의 TestSession 함수를 실행합니다.
즉, 임계구역안에 임계구역을 만들고 있습니다.
그리고 추가적으로 생성한 스레드들이 사용할 함수인 Thread_1과 Thread_2에서는
SessionManager와 ClientManager의 Test 함수를 각각 10000번씩 반복합니다.
이제 main 스레드에서 동시에 Thread_1과 Thread_2를 수행하는 Task를 만들고 실행해보겠습니다.
결과는 다음과 같습니다.
스레드간의 교착 상태가 발생하여 먹통이 된 것을 확인할 수 있습니다.
자세히 알아보기 위해 중단점을 걸고 모두 중지를 선택하면
위와 같이 DeadLock이 발견되었다는 에러 메세지가 뜨는 것을 확인할 수 있습니다.
이런 상황에서 코드를 한번 수정해보도록 하겠습니다.
2) 코드를 일부 수정
class SessionManager
{
static object _key = new object();
public static void Test()
{
lock (_key)
{
ClientManager.TestClient();
}
}
public static void TestSession()
{
lock(_key)
{
}
}
}
class ClientManager
{
static object _key = new object();
public static void Test()
{
lock (_key)
{
SessionManager.TestSession();
}
}
public static void TestClient()
{
lock (_key)
{
}
}
}
class Program
{
static int number = 0;
static void Thread_1()
{
for (int i = 0; i < 100; i++)
{
SessionManager.Test();
}
}
static void Thread_2()
{
for (int i = 0; i < 100; i++)
{
ClientManager.Test();
}
}
static void Main(string[] args)
{
Task t1 = new Task(Thread_1);
Task t2 = new Task(Thread_2);
t1.Start();
Thread.Sleep(100);
t2.Start();
Task.WaitAll(t1, t2);
Console.WriteLine(number);
}
}
t2 태스크를 0.1초 뒤에 실행시켜보도록 하겠습니다.
실행결과를 살펴보면
위와 같이 잘 실행되는 것을 확인할 수 있습니다.
0.1초 차이임에도 이번에는 데드락이 발생하지 않는 것을 확인할 수 있습니다.
바로 위와 같은 이유가 데드락을 쉽게 예측하지 못하게끔 합니다.
그리고 이는 데드락이 개발 과정보다 서비스 하는 과정에서 더 자주 발생하는 이유이기도 합니다.
개발 과정에서는 유저들의 접속과 같은 변동 사항이 적기 때문에 데드락이 덜 발생하지만,
서비스 과정에서는 많은 변동 사항이 발생하기에 데드락도 더 발생하게 됩니다.
결국 데드락은 발생하는 순간 고치는 방향으로 갈피를 잡아야 합니다.
그렇다면 되도록 lock을 설계할 때 문제를 덜 일으키는 방식으로 가야 합니다.
그렇다면 어떤 방식으로 lock을 설계해야 하는지 생각해보겠습니다.
3) Lock의 구현
첫 번째로 고려해 볼만한 사항은 스핀락입니다.
스핀락에서는 자원 점유가 풀릴 때까지 대기하는 방식입니다.
가장 간단하지만 대기하는 시간이 길수록 손해를 발생시킵니다.
또한 대기하는 시간동안 CPU의 점유율이 매우 높아져 다른 연산에 악영향을 끼칠 수 있습니다.
두 번째로 고려해 볼만한 사항은 ContextSwitching입니다.
자원이 점유되었다면 대기하지 않고 하던 일을 마저 하다가 일정 시간 후에 자원 점유 여부를 확인합니다.
Thread.Sleep(0)이나 yield로도 구현이 가능합니다.
기존의 스핀락에 비하면 효율적이지만 점유여부를 확인하고자 왔다갔다 하는 과정에서 손실이 큽니다.
마지막으로 고려해 볼만한 사항은 Event입니다.
매니저 클래스를 통해 Event를 연결하여 자원의 점유여부를 확인합니다.
자원의 점유가 풀렸다면 Event를 호출해 알려줍니다.
위와 같은 방식외에도 효과적인 Lock을 구현하는 방식은 여러가지가 존재합니다.
'C#' 카테고리의 다른 글
Context Switching (0) | 2024.12.04 |
---|---|
SpinLock (0) | 2024.12.04 |
Lock 기초 (1) | 2024.12.02 |
Interlocked (0) | 2024.11.27 |
Memory Barrier (0) | 2024.11.27 |