NeoSmart.AsyncLock 라이브러리에 관하여

다음에서 발췌, 번역 - Neosmart Docs.

개요


semaporeslim 은 reentrance 를 지원하지 않는다. 따라서, recursion 에서 적절히 사용되지 않으면 데드락이 발생한다.
asynclock 은 reentrance 기능을 semaphoreslim 에 추가한거.

대안


간단한 방법은 semaphoreslim 으로 교체하고, recursion 인 경우를 스레드 아이디로 확인 하는 것.
이 경우의 문제는
async / await 의 가장 기본적인 목적인 ui 의 불필요한 블럭킹 없이 작업의 완료를 기다린다는 문제를 그대로 안고 있다.

await 코드를 넣어도 다른 코드가 실행 될 수 없다.

class ThreadIdConflict
{
    BadAsyncLock _lock = new BadAsyncLock();

    async void Button1_Click()
    {
        using (_lock.Lock())
        {
            await Task.Delay(-1); //at this point, control goes back to the UI thread
        }
    }

    async void Button2_Click()
    {
        using (_lock.Lock())
        {
            await Task.Delay(-1); //at this point, control goes back to the UI thread
        }
    }
}

원래 메인스레드는 메시지 펌핑을 하면서 콜백을 호출해주는 구조로 되어 있고,

“hard” await 을 마주쳐서 메인 ui 로 돌아갈때도
이벤트 핸들러의 실행을 일시 정지하지만 실제 스레드가 동작을 멈추지는 않는다.
await 이 완료 되고 나면, continuation 이 다시 main 스레드에서 실행된다.

여기에서 중요한 것은, 항상 같은 스레드가 실행된다는 것이다. (non- awaited async 함수 호출을 제외하고.) Button1_Click() 을 실행한 스레드가 await 을 만나 동작을 정지하고, 이후 Button2_cllick 을 호출한다.
Button1_click() 의 남은 코드는 옆에 놓여지는거지, 실제로 정지 되는것이 아니다. 이 의미는, Button2_click 이 실행되어야할 때 Button1_click() 은 세마포어를 통해 상호 배제적인 접근을 하고 있으므로 접근 불가해야하나, owningthreadId 가 같으므로 두 메소드가 동시에 실행된다.

AsyncLock


그럼 어떻게 해야하는가? recursion 을 체크하기위해 뭔가 다른 방법을 찾아야한다. Envrionment 클래스를 통해 스택 트레이스에 접근 할 수 있다. 이를 락을 얻기 위한 요건으로 사용할 수 있지 않을까 ?

Update 5/25/2017 (AsyncLock 은 이제는 taskid 를 통해 확인하고 있다. )

List _stackTraces = new List();
async Task Lock()
{
    if (!lock.locked)
    {
        _stackTraces.Add(Environment.StackTrace);
        lock.Wait();
        return true;
    }
    else if (_stackTraces.Peek().IsParentOf(Environment.StackTrace))
    {
        _stackTraces.Add(Environment.StackTrace);
        return true;
    }
    else
    {
        //wait for the lock to become available somehow
        return true;
    }
}

Lock() 의 호출이 스택추적을 낭비하지 않는다고 가정하면,(?) isParentOf 메소드가 현재 호출이 저장된 스택트레이스의 자식인지 확인한다.

하지만 이런 접근은 첫번째 솔루션으로는 쉽게 해결 됐을 다음 코드를 처리하지 못한다.

class StackTraceConflict
{
    BadAsyncLock _lock = new BadAsyncLock();

    async void DoSomething()
    {
        using (_lock.Lock())
        {
            await Task.Delay(-1);
        }
    }

    void DoManySomethings()
    {
        while(true)
        {
            DoSomething(); //no wait here!
        }
    }
}

모두 같은 지점에서 실행되기 때문에 다른 스레드에서 같은 스택트레이스를 갖게 되고 완벽하게 실패하게 된다!

따라서 적절한 솔루션은, 두 솔루션을 결합하는 것이다.

class AsyncLockTest
{
    AsyncLock _lock = new AsyncLock();
    void Test()
    {
        //the code below will be run immediately (and asynchronously, in a new thread)
        Task.Run(async () =>
        {
            //this first call to LockAsync() will obtain the lock without blocking
            using (await _lock.LockAsync())
            {
                //this second call to LockAsync() will be recognized as being a reëntrant call and go through
                using (await _lock.LockAsync())
                {
                    //we now hold the lock exclusively and no one else can use it for 1 minute
                    await Task.Delay(TimeSpan.FromMinutes(1));
                }
            }
        }).Wait(TimeSpan.FromSeconds(30));

        //this call to obtain the lock is synchronously made from the main thread
        //It will, however, block until the asynchronous code which obtained the lock above finishes
        using (_lock.Lock())
        {
            //now we have obtained exclusive access
        }
    }
}

task 가 먼저 실행되도록 하기위해 30 초를 대기했다가 평범하게 락을 건다.
첫번째 락은 평범하게 얻어진 뒤에, 다시 reentrant call 이 발생하고, 이것 또한 넘어가게 된다. (# await 실행된 스레드아이디 + 실행된 콜스택의 부모)

Task.Delay 를 마주쳐서 스레드는 pause 상태로 전환되고, 이 시간동안 공유되는 리소스에 대해 배제적 접근을 하게 된다.

30 초 뒤에 lock 을 얻으려고 시도할때, 이 시도는 실패하게 되고
다시 30초 뒤에 task 가 완료되어 lock 을 release 하게 되면 메인스레드가 락을 얻어 동작이 재개 된다.

이 코드 조각은 두개의 락 옵션을 사용하고 있다. Lock() 과 LockAsync() 인데, 이들은 둘다 기본 개념은 같고, async 메소드는 async/ await 패러다임을 품어 이 실행이 lock 이 사용 가능할때에 새로 얻을 수 있도록 한 개념이다. 이렇게 해서 await lock.LockAsync() 가 블러킹 되지 않도록 한 것이다.