diff --git a/src/Stunts.UnitTests/Scenarios/Synchronized.cs b/src/Stunts.UnitTests/Scenarios/Synchronized.cs new file mode 100644 index 0000000..fa11946 --- /dev/null +++ b/src/Stunts.UnitTests/Scenarios/Synchronized.cs @@ -0,0 +1,209 @@ +#pragma warning disable IDE0079 +#pragma warning disable CS0436 +using System; +using System.Threading; +using System.Threading.Tasks; +using Stunts; +using Xunit; + +namespace Stunts.Scenarios.Synchronized +{ + public interface IWorker + { + int Work(int id); + Task WorkAsync(int id); + ValueTask WorkValueAsync(int id); + int Reentrant(); + } + + /// + /// A synchronized behavior serializes overlapping calls using a standard lock. + /// + public class Test : IRunnable + { + public void Run() + { + OverlappingSyncCalls_RunOneAtATime(); + ReentrantCall_Succeeds_WithReentrantLock(); + SharedLock_CoordinatesTwoStunts(); + DefaultLock_DoesNotCoordinateTwoStunts(); + } + + public void OverlappingSyncCalls_RunOneAtATime() + { + var stunt = Stunt.For(); + stunt.AddBehavior(new SynchronizedBehavior()); + var work = new TrackingWork(); + stunt.AddBehavior(work); + IWorker worker = stunt.ToObject(); + + var barrier = new Barrier(2); + var tasks = new Task[2]; + for (int i = 0; i < 2; i++) + tasks[i] = Task.Run(() => + { + barrier.SignalAndWait(); + worker.Work(1); + }); + + Task.WaitAll(tasks); + + Assert.Equal(1, work.MaxConcurrent); + Assert.Equal(2, work.Calls); + } + + public void ReentrantCall_Succeeds_WithReentrantLock() + { + var stunt = Stunt.For(); + stunt.AddBehavior(new SynchronizedBehavior()); + IWorker? worker = null; + stunt.AddBehavior(new ReentrantWork(v => worker!.Work(1))); + stunt.AddBehavior(new LambdaWork(invocation => + invocation.MethodBase.Name == nameof(IWorker.Work) + ? new MethodReturn(invocation, 0, invocation.Arguments) + : new MethodReturn(invocation, 0, invocation.Arguments))); + worker = stunt.ToObject(); + + // Reentrant Monitor: same-thread re-entry succeeds (no deadlock, no throw). + var result = worker.Reentrant(); + Assert.Equal(0, result); + } + + public void SharedLock_CoordinatesTwoStunts() + { + var syncRoot = new object(); + var barrier = new Barrier(2); + int concurrent = 0; + int maxConcurrent = 0; + + IWorker Make() + { + var stunt = Stunt.For(); + stunt.AddBehavior(new SynchronizedBehavior(syncRoot)); + stunt.AddBehavior(new LambdaWork(invocation => + { + var c = Interlocked.Increment(ref concurrent); + int max; + do { max = maxConcurrent; } + while (c > max && Interlocked.CompareExchange(ref maxConcurrent, c, max) != max); + try + { + barrier.SignalAndWait(5000); + Thread.Sleep(50); + return new MethodReturn(invocation, 0, invocation.Arguments); + } + finally + { + Interlocked.Decrement(ref concurrent); + } + })); + return stunt.ToObject(); + } + + var w1 = Make(); + var w2 = Make(); + + var t1 = Task.Run(() => w1.Work(1)); + var t2 = Task.Run(() => w2.Work(2)); + Task.WaitAll(t1, t2); + + Assert.Equal(1, maxConcurrent); + } + + public void DefaultLock_DoesNotCoordinateTwoStunts() + { + var barrier = new Barrier(2); + int concurrent = 0; + int maxConcurrent = 0; + + IWorker Make() + { + var stunt = Stunt.For(); + stunt.AddBehavior(new SynchronizedBehavior()); + stunt.AddBehavior(new LambdaWork(invocation => + { + var c = Interlocked.Increment(ref concurrent); + int max; + do { max = maxConcurrent; } + while (c > max && Interlocked.CompareExchange(ref maxConcurrent, c, max) != max); + try + { + barrier.SignalAndWait(5000); + Thread.Sleep(50); + return new MethodReturn(invocation, 0, invocation.Arguments); + } + finally + { + Interlocked.Decrement(ref concurrent); + } + })); + return stunt.ToObject(); + } + + var w1 = Make(); + var w2 = Make(); + + var t1 = Task.Run(() => w1.Work(1)); + var t2 = Task.Run(() => w2.Work(2)); + Task.WaitAll(t1, t2); + + Assert.Equal(2, maxConcurrent); + } + + class TrackingWork : IStuntBehavior + { + public int Calls; + public int MaxConcurrent; + int current; + + public bool AppliesTo(IMethodInvocation invocation) + => invocation.MethodBase.Name == nameof(IWorker.Work); + + public IMethodReturn Execute(IMethodInvocation invocation, ExecuteHandler next) + { + var c = Interlocked.Increment(ref current); + int max; + do { max = MaxConcurrent; } + while (c > max && Interlocked.CompareExchange(ref MaxConcurrent, c, max) != max); + try + { + Interlocked.Increment(ref Calls); + Thread.Sleep(50); + return new MethodReturn(invocation, 0, invocation.Arguments); + } + finally + { + Interlocked.Decrement(ref current); + } + } + } + + class ReentrantWork : IStuntBehavior + { + readonly Action reenter; + + public ReentrantWork(Action reenter) => this.reenter = reenter; + + public bool AppliesTo(IMethodInvocation invocation) + => invocation.MethodBase.Name == nameof(IWorker.Reentrant); + + public IMethodReturn Execute(IMethodInvocation invocation, ExecuteHandler next) + { + reenter(invocation); + return new MethodReturn(invocation, 0, invocation.Arguments); + } + } + + class LambdaWork : IStuntBehavior + { + readonly Func handler; + + public LambdaWork(Func handler) => this.handler = handler; + + public bool AppliesTo(IMethodInvocation invocation) => true; + + public IMethodReturn Execute(IMethodInvocation invocation, ExecuteHandler next) + => handler(invocation); + } + } +} diff --git a/src/Stunts/SynchronizedBehavior.cs b/src/Stunts/SynchronizedBehavior.cs new file mode 100644 index 0000000..a0b9dc9 --- /dev/null +++ b/src/Stunts/SynchronizedBehavior.cs @@ -0,0 +1,76 @@ +using System; + +namespace Stunts +{ + /// + /// An that serializes overlapping calls on a stunt + /// using a standard lock. + /// + /// + /// The lock is acquired before next runs and released when it returns, + /// following the normal lock statement semantics (reentrant). + /// + /// By default each behavior owns a private lock object, so one behavior locks + /// one stunt. Pass an explicit syncRoot to coordinate multiple behaviors + /// on the same lock. + /// + /// + /// The behavior implements : the default private lock + /// is fresh per clone, while an explicit lock object stays shared across clones. + /// + /// + public class SynchronizedBehavior : IStuntBehavior, ICloneable + { + readonly object syncRoot; + readonly bool isShared; + + /// + /// Initializes the behavior with a private lock owned by the behavior. + /// + public SynchronizedBehavior() : this(new object(), false) { } + + /// + /// Initializes the behavior. + /// + /// + /// Optional lock object. When (default), the behavior + /// uses a private object, so one behavior locks one stunt. When provided, + /// the behavior locks on that object. + /// + public SynchronizedBehavior(object? syncRoot) + : this(syncRoot ?? new object(), syncRoot != null) + { + } + + SynchronizedBehavior(object syncRoot, bool isShared) + { + this.syncRoot = syncRoot; + this.isShared = isShared; + } + + /// + /// Returns a new . A default private lock + /// is fresh per clone; an explicit lock object stays shared. + /// + public object Clone() => new SynchronizedBehavior( + isShared ? syncRoot : new object(), + isShared); + + /// + /// Applies to every method invocation: overlapping calls on the stunt are + /// excluded from running concurrently. + /// + public bool AppliesTo(IMethodInvocation invocation) => true; + + /// + /// Acquires the lock, invokes , and releases the lock. + /// + public IMethodReturn Execute(IMethodInvocation invocation, ExecuteHandler next) + { + lock (syncRoot) + { + return next(invocation, next); + } + } + } +}