InSync 2.4.0

dotnet add package InSync --version 2.4.0
NuGet\Install-Package InSync -Version 2.4.0
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="InSync" Version="2.4.0" />
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add InSync --version 2.4.0
#r "nuget: InSync, 2.4.0"
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install InSync as a Cake Addin
#addin nuget:?package=InSync&version=2.4.0

// Install InSync as a Cake Tool
#tool nuget:?package=InSync&version=2.4.0

InSync is a general purpose library providing easy ways to correctly write thread-safe code for .NET.

It is easy to forget to acquire correct locks before accessing variables in moderately large classes. It is also difficult to spot the errors in code review. InSync introduces the patterns popular in C++ to solve the problem.

Features

  • Enforce lock acquisition
  • Automatic lock release
  • A built-in dead-lock free algorithm to acquire multiple locks
  • Easy migration
  • High performance

Quick Start

In this example, we have a supposedly thread-safe class:

class QuickStart
{
    private int X;
    private int Y;
    private readonly object padLock = new object();

    public void Foo(int add)
    {
        lock (padLock)
        {
            X += 1;
            Y += add;
        }
    }

    public void Bar(int subtract)
    {
        // oops
        X -= 1;
        Y -= subtract;
    }
    
    public (int X, int Y) Get()
    {
        lock (padLock)
        {
            return (X, Y);
        }
    }
}

InSync prevents this kind of bug:

class QuickStart
{
    private class State
    {
        public int X { get; set; }
        public int Y { get; set; }
    }

    private readonly Synchronized<State> state = Synchronized.Create(new State());

    public void Foo(int z)
    {
        state.WithLock(s =>
        {
            s.X += 1;
            s.Y += z;
        });
    }

    public void Bar(int z)
    {
        // alternative style
        using (var guard = state.Lock())
        {
            guard.Value.X -= 1;
            guard.Value.Y -= z;
        }
    }

    public (int X, int Y) Get()
    {
        // passes the return value
        return state.WithLock(s =>
        {
            return (s.X, s.Y);
        });
    }

    private void ReusableMethod(State state)
    {
        // useful for AsyncSynchronized<T> and ReaderWriterSynchronized<T> because they are non-reentrant
        state.X += 1;
    }
}

Usage

Single Lock

There are 3 types of synchronization objects. The API is similar among them.

  • Synchronized<T> which uses Monitor
  • AsyncSynchronized<T> which uses SemaphoreSlim
  • ReaderWriterSynchronized<T> which uses ReaderWriterLockSlim

They can be created by constructors or convenient factory methods. For example:

var s1 = Synchronized.Create(value1);
var s2 = Synchronized.Create(padLock, value2);

Without supplying an object as the lock, Synchronized<T> uses the value as the lock. In contrast, AsyncSynchronized<T> and ReaderWriterSynchronized<T> creates new locks.

For automatical releases, there are 2 styles to access the value inside a synchronization object, WithLock with closures and Lock with using.

public interface ISynchronized<T> where T : class
{
    void WithLock(Action<T> action);

    TResult WithLock<TResult>(Func<T, TResult> func);

    bool TryWithLock(Action<T> action);

    bool TryWithLock<TResult>(Func<T, TResult> func, out TResult result);

    GuardedValue<T> Lock();

    GuardedValue<T> TryLock();
}

For example, WithLock with closures:

private readonly Synchronized<List<int>> list = Synchronized.Create(new List<int>());

public void WithLock()
{
    list.WithLock(list =>
    {
        list.Add(0);
        Console.WriteLine("locking");
    });
}

public int WithLockReturn()
{
    return list.WithLock(list => list.FirstOrDefault());
}

public void TryWithLock()
{
    if (list.TryWithLock(list => list.Add(0)))
    {
        Console.WriteLine("added");
    }
}

and Lock with using:

private readonly Synchronized<List<int>> list = Synchronized.Create(new List<int>());

public int Guard()
{
    using (var guard = list.Lock())
    {
        return guard.Value.FirstOrDefault();
    }
}

public int TryGuard()
{
    using (var guard = list.TryLock())
    {
        if (guard != null)
        {
            Console.WriteLine("locking");
            return guard.Value.FirstOrDefault();
        }
        Console.WriteLine("not locked");
        return -1;
    }
}

For manual releases:

public interface IBareLock
{
    object BarelyLock();

    bool BarelyTryLock(out object value);
}

public interface IBareLock<T> : IBareLock where T : class
{
    new T BarelyLock();
    
    bool BarelyTryLock(out T value);
}

Struct and Mutable Reference

ValueContainer<T> provides a way to store struct or mutate the value:

private readonly Synchronized<ValueContainer<int>> container = Synchronized.Create(new ValueContainer<int>());

public void Increase()
{
    container.WithLock(c =>
    {
        ++c.Value;
    });
}

Multiple Locks

MultiSync provides easy ways to acquire multiple locks without deadlocks. It does not require any setup nor lock organizations. Fairness is thread-based and provided by the OS because Thread.Yield is used. Livelock may occur for a very short period under high contention. In such case, CPU power is wasted.

It uses the smart and polite method described in https://howardhinnant.github.io/dining_philosophers.html#Polite. Basically, it tries to acquire the locks one by one. If an acquisition fails, it releases all acquired locks. Before a blocking retry of the last acquisition, it yields to let other threads to process first.

private readonly Synchronized<List<int>> lock1 = Synchronized.Create(new List<int>());
private readonly Synchronized<List<int>> lock2 = Synchronized.Create(new List<int>());

public void UnorderedAcquisition()
{
    using (var guard = MultiSync.All(lock1, lock2))
    {
        var list1 = guard.Value.Item1;
        var list2 = guard.Value.Item2;
        list1.AddRange(list2);
    }
}

Synchronization Token

For some reasons, if the style shown by ResuableMethod in the quick start is not preferred, it is still possible to enforce locking for methods at compile time:

private class WriteToken
{
    private WriteToken() { }

    /// <summary>
    /// This should be created once per object only.
    /// </summary>
    /// <returns></returns>
    public static WriteToken CreatePerObjectOnly() => new WriteToken();
}

private readonly AsyncSynchronized<WriteToken> writeToken = AsyncSynchronized.Create(WriteToken.CreatePerObjectOnly());

public async Task Foo()
{
    using (var w = await writeToken.LockAsync())
    {
        ReusableMethod(w.Value);
    }
}

public async Task Bar()
{
    using (var w = await writeToken.LockAsync())
    {
        ReusableMethod(w.Value);
    }
}

private void ReusableMethod(WriteToken token)
{
    // if (token == null)
    //     throw new ArgumentNullException(nameof(token));
    // some complicated code
}

It is better than purely relying on documenting the methods and hope that callers do right.

Pitfalls

Async with Synchronized<T>

await must not be used between locking and unlocking by Synchronized<T>. AsyncSynchronized<T> should be used instead in the following example:

public void NotThreadSafe()
{
    list.WithLock(async list => // 1. Monitor.Enter
    {
        await Task.Delay(1);

        // 3. This may resume after Monitor.Exit

        list.Add(0);

    }); // 2. Monitor.Exit
}

public async Task Throw()
{
    using (var guard = list.Lock()) // Monitor.Enter
    {
        await Task.Delay(1);

        // This may resume in an unspecified thread

        guard.Value.Add(0);

    } // Monitor.Exit may be called in the unspecified thread
}

Dispose() Throws!

GuardedValue<T> and GuardedMultiValue<T> may throw if releases of locks fail. Dispose() is not expected to throw exceptions or otherwise it results in crashes. Failures in releasing locks usually cause difficult to debug deadlocks later. It is even worst than immediate crashes. Generally, releasing locks should not throw too. Thus, bubbling up the exceptions is a lesser evil than sallowing them.

Still not thread-safe

If a plain reference to a Synchronized<T> is written by a thread then read by another thread, it is still not thread-safe:

class Wrong
{
    private Synchronized<object> obj;

    public void Foo()
    {
        new Thread(() =>
        {
            obj?.WithLock(o =>
            {
                Console.WriteLine(o.ToString());
            });
        }).Start();

        obj = Synchronized.Create(new object());
    }
}

The variable needs some synchronizations. For the above exmaple, volatile is sufficient:

class Correct
{
    volatile Synchronized<object> obj;

    public void Foo()
    {
        new Thread(() =>
        {
            obj?.WithLock(o =>
            {
                Console.WriteLine(o.ToString());
            });
        }).Start();

        obj = Synchronized.Create(new object());
    }
}

However, volatile is error-prone for more complicated usages. Synchronized<ValueContainer<T>> is a better choice:

class Correct
{
    private readonly Synchronized<ValueContainer<object>> obj = Synchronized.Create(new ValueContainer<object>());

    public void Foo()
    {
        new Thread(() =>
        {
            obj.WithLock(container =>
            {
                Console.WriteLine(container.Value?.ToString());
            });
        }).Start();

        obj.WithLock(container => container.Value = new object());
    }
}

Not abort-safe

This library does not support Thread.Abort(). It may leave some locks being permanently locked.

Performance

The full benchmark result is available at https://keithyipkw.github.io/InSync/performance.html

The benchmark program was run with the environment:

  • InSync 1.0.0
  • Targeting .NET Core 2.1
  • .NET SDK 5.0.101
  • Windows 10
  • Ryzen 3700X
    • fix clock at 4GHz
    • SMT was disabled
  • StopWatch resolution 100ns
  • High process priority

Single Lock

With no contentions, execution time of different methods of acquiring 1 lock was measured. Because of insufficient resolution of StopWatch, each method was repeated 200,000 times. The averages of time in nanoseconds were calculated. 100 times of such measurement were ran to get 100 averages for each method. Core 4 was used.

Method Mean STD
Loop overhead 0.251335 0.003273752
lock 10.44946 0.037760254
Synchronized.WithLock 11.077325 0.125649291
Synchronized.Lock 24.920565 0.589758128
SemaphoreSlim.Wait 26.13611 0.300629437
AsyncSynchronized.WithLock 37.637085 0.366919775
AsyncSynchronized.Lock 51.63986 0.724153676
SemaphoreSlim.WaitAsync 37.379345 0.691119172
AsyncSynchronized.WithLockAsync 95.74239 0.231985083
AsyncSynchronized.LockAsync 110.948145 0.605324165

Multiple Locks

Similarly, that of acquiring 2 locks was measured.

Method Mean STD
Loop overhead 0.501575 0.001162058
lock 20.69993 0.188203361
Synchronized.Lock 42.319905 0.945111729
MultiSync.All Monitor 102.502125 1.99298697
MultiSync.All Monitor reusing array 98.72238 1.695725165
SemaphoreSlim.WaitAsync 73.396305 0.943295799
MultiSync.AllAsync SemaphoreSlim 321.813115 1.390673596

Dining Philosophers

The original C++ code was rewritten in C# but with 10s eating time. For the 4-core case, 11 times was run using core 4, 5, 6 and 7.

dining_core_4

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 is compatible. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • .NETStandard 2.0

    • No dependencies.
  • .NETStandard 2.1

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
2.4.0 436 7/3/2021
2.3.0 429 1/30/2021
2.2.0 310 1/26/2021
2.1.0 341 1/18/2021
2.0.0 365 1/9/2021
1.0.0 374 12/27/2020