[C#] 멀티태스킹(Multitasking) - 3. 공유 자원 접근
4. 공유 자원 접근
같은 시간에 여러 스레드를 실행시키는 경우 2개나 혹은 그 이상의 스레드가 같은 변수나 기타 다른 리소스에 접근을 시도하는 경우가 생길 수 있습니다. 이는 Application의 안정성에 문제를 일으킬 수 있으므로 스레드 안전한(thread-safe) 코드를 만들도록 해야 합니다.
우선 공유된 자원에 대한 접근을 동기화하는 데 사용될 수 있는 아래 2가지 타입을 알아볼 필요가 있습니다.
- Monitor : 여러 스레드가 같은 프로세스 안에서 공유된 자원에 접근할 수 있는지를 확인하는 데 사용되는 개체입니다.
- Interlocked : CPU 수준에서 단순한 수치를 조작하기 위한 개체입니다.
(1) 다중 스레드로부터의 자원 접근
해당 사례를 구현하기 위해 우선 아래와 같이 공유할 변수를 가진 클래스를 생성하고
static class SharedObject
{
public static string? str;
}
위의 str변수에 접근을 시도해 문자열 값을 구성하는 2개의 메서드를 추가합니다.
static void MethodA()
{
for (var i = 0; i < 5; i++) {
Thread.Sleep(1000);
SharedObject.str += "A";
}
}
static void MethodB()
{
for (var i = 0; i < 5; i++) {
Thread.Sleep(1000);
SharedObject.str += "B";
}
}
그리고 위 메서드를 각각 분리된 task로 실행하고 결과를 확인합니다.
Task a = Task.Factory.StartNew(MethodA);
Task b = Task.Factory.StartNew(MethodB);
Task.WaitAll(new Task[] { a, b });
Console.WriteLine(SharedObject.str);
//AAABABBA
결과를 보면 2개의 메서드에서 str에 동시에 문자열값을 적용하고 있음을 알 수 있습니다. 실제 Application에서는 이러한 동작이 문제가 될 여지가 있으므로 아래와 같은 방법으로 문제점을 보완할 수 있습니다.
(2) exclusive lock
위의 상황을 해결할 수 있는 방법 중 하나는 상호 배타적인(독점적인) lock을 적용하는 것입니다. 이를 위해 SharedObject에서 임의의 객체에 대한 인스턴스를 생성하고
static class SharedObject
{
public static object obj = new();
public static string? str;
}
MethodA와 MethodB각각에 lock을 위한 구문을 추가합니다.
static void MethodA()
{
lock (SharedObject.obj)
{
for (var i = 0; i < 5; i++)
{
Thread.Sleep(1000);
SharedObject.str += "A";
}
}
}
static void MethodB()
{
lock (SharedObject.obj)
{
for (var i = 0; i < 5; i++)
{
Thread.Sleep(1000);
SharedObject.str += "B";
}
}
}
실행결과를 보면
BBBBBAAAAA
|
하나의 메서드에서 str에 대한 작업이 완전히 종료되고 나서야 다시 다른 메서드에서 str의 값을 설정하고 있음을 알 수 있습니다. 이는 비록 Application의 실행시간을 다소 늘리기는 하지만 하나의 자원을 공유하는 동작에서 자원 접근에 대한 안정성을 훨씬 더 높일 수 있습니다.
예제에서는 lock의 생성을 위해 obj의 인스턴스를 생성하고 여기에 lock을 걸어두는 방법으로 자원에 대한 중복 접근을 차단하고 있습니다. 그런데 사실 lock구문 자체가 어떤 역할을 수행하는 것은 아니고 컴파일러가 이 lock구문을 만나게 되면 이를 아래와 같이 try ~ catch구문으로 바꿔주게 됩니다.
try
{
Monitor.Enter(SharedObject.obj);
}
finally
{
Monitor.Exit(SharedObject.obj);
}
스레드가 참조 형식의 객체를 매개변수로 갖고 있는 Monitor의 Enter() 메서드를 호출할 때 다른 스레드에서 이미 해당 객체를 가 진상 태인 지를 확인하고 그렇다면 스레드는 그대로 대기하게 되지만 해당 객체를 가진 다른 스레드가 없다면 해당 스레드를 가진 뒤 공유자원에 대한 접근을 시도하게 됩니다. 예제에서 obj가 공유자원에 접근이 가능한지를 확인하는 매개체가 되는 것입니다.
스레드가 작업을 종료하게 되면 Exit() 메서드를 호출하게 되고 가지고 있는 obj는 해당 스레드에서 풀려나게 됩니다. 이때 대기 중인 스레드가 있다면 풀려난 객체를 잡고 자신의 작업을 진행하게 됩니다.
● Deadlock 회피하기
위와 같은 사례에서 한 가지 문제가 될 수 있는 부분은 Deadlock이 걸리는 경우입니다.
예를 들어 메서드 A에서 A객체에 Lock을 걸고 A라는 작업을 수행할때 메서드B에서도 B객체에 Lock을 걸고 B라는 작업을 수행한다고 가정해 보겠습니다. 메서드A가 동작중 B의 작원접근이 필요해 B에 대한 접근을 시도하면 B는 이미 메서드B에서 사용 중이므로 메서드A는 메서드B의 작업이 종료될때는 기다리게 됩니다. 이때 메서드B에서도 A에 대한 접근이 필요해 접근을 시도하게 되면 역시 메서드A에서 이미 A를 사용중이므로 메서드 B 역시 메서드 A의 작업이 종료될 때까지 기다리게 됩니다.
이러한 시나리오에서는 Application에서 각 스레드가 무한 대기상태에 빠지게 될 수 있게 되는데 이러한 상황을 Deadlock이라고 표현하며 Deadlock을 회피하기 위해서는 lock구문을 사용하는 대신 위에서 봤었던 Monotor를 직접 사용해 스레드가 대기할 수 있는 타임아웃을 설정해야 합니다.
static void MethodA()
{
try
{
if (Monitor.TryEnter(SharedObject.obj, TimeSpan.FromSeconds(15)))
{
for (var i = 0; i < 5; i++)
{
Thread.Sleep(1000);
SharedObject.str += "A";
}
}
else
{
Console.WriteLine("타임아웃");
}
}
finally
{
Monitor.Exit(SharedObject.obj);
}
}
어떤 방식으로든 Deadlock을 피할 수 있다면 lock구문을 사용해도 되지만 그럴 수 없는 상황이라면 Monitor클래스를 사용할 것을 권합니다.
참고로 exclusive lock은 이벤트를 추가하거나 삭제 혹은 이벤트 자체를 발생시키는 경우에도 사용되는 경우가 있습니다. .NET 이벤트는 기본적으로 스레드에 안전하지 않기 때문에 이에 대한 대안으로 사용되는데 이는 좋은 선택이 아닙니다.
Threadsafe Events (stephencleary.com)
(3) '원자적'으로 정의된 접근 방법을 사용한 상호 배제 구현
원자적(Atomic)이라는 말은 그리스의 atomos에서 나온 말로 더 이상 나눌 수 없는 것을 말합니다. 이것은 멀티스레딩에서 어떤 연산이 원자적인 것인지를 이해하는데 중요한 부분입니다. 왜냐하면 만약 연산이 원자적이지 않다면 다른 스레드에 의해서 연산이 중단될 수 있기 때문입니다.
예를 들어
int i = 10;
i++;
과 같은 경우 위의 연산은 원자적이라고 할 수 없습니다. 왜냐하면 위의 처리를 위해 CPU는 3가지 동작을 수행해야 하기 때문입니다.
- 변수로부터 값을 메모리에 로드합니다.
- 값을 증가합니다.
- 증가된 값을 다시 변수에 저장합니다.
극단적으로 처음 스레드는 위의 과정 중 2번째까지 실행된 후 인터럽트에 의해 중단될 수 있습니다. 이때 다른 스레드에서는 위의 모든 단계를 수행할 수 있고 다시 처음 스레드가 나머지 과정을 재개할 때 변수의 값은 덮어써지게 되며 결국 다른 스레드에서 적용된 값이 소실될 수 있습니다.
이때 Interlocked을 사용하면 값 type(int나 float와 같은...)에 대한 동작을 '원자적'으로 수행할 수 있습니다. 위의 예제에서 SharedObject 클래스에 얼마나 많은 연산이 이루어지는지를 나타내는 변수(OperationCounter)를 아래와 같이 선언합니다.
static class SharedObject
{
public static object Obj = new();
public static int OperationCounter;
public static string? Str;
}
그리고 MethodA()와 MethodB()에서 Str에 대한 값을 변경한 이후에 다음 구문을 추가하여 OperationCounter의 값이 안전하게 증가될 수 있도록 합니다.
static void MethodA()
{
lock (SharedObject.Obj)
{
for (var i = 0; i < 5; i++)
{
Thread.Sleep(1000);
SharedObject.Str += "A";
Interlocked.Increment(ref SharedObject.OperationCounter);
}
}
}
물론 위 예제에서는 SharedObject의 Obj를 통해 다른 스레드 간 간섭을 방지하고 있으므로 실제 Interlocked.Increment()의 구현은 필요하지 않을 수 있지만 그렇지 않은 경우라면 Interlocked.Increment()를 사용해줄 필요가 있습니다.
(4) 기타 동기화를 위한 Type
Monitor와 Interlocked는 간단하면서도 효과적으로 상호 배타적 잠금을 구현할 수 있지만 때로는 공유자원에 동기적으로 접근하는 경우를 위한 더 상세한 옵션이 필요할 수 있고 그런 경우 아래와 같은 Type의 사용을 고려해 볼 수 있습니다.
Type | 용도 |
ReaderWriterLock, ReaderWriterLockSlim | 다중 읽기와 소수의 쓰기에 대한 스레드를 다루는 상황에서 공유자원을 보호하기 위해 사용할 수 있습니다. 단, 잠금이 이루어지는 동작이 일정시간이상 필요한 경우에만 그 효과를 발휘할 수 있습니다. |
Mutex | Monitor처럼 공유자원에 대해 독점적 접근을 제공하지만 프로세스간 동기화에 사용되는 경우는 제외합니다. |
Semaphore, SemaphoreSlim | 슬롯을 정의하여 리소스 또는 리소스풀에 동시에 접근할 수 있는 스레드의 수를 제한합니다. 이것은 리소스 잠금보다는 리소스 쓰로틀링으로 알려져 있습니다. |
AutoResetEvent, ManualResetEvent | 이벤트 대기 핸들은 스레드가 서로에게 신호를 보내고 서로의 신호를 대기함으로서 동기화를 할 수 있도록 합니다. |