From f62a0aa7da2909849793bb24c644c0d57ed2bb75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iva=20Tuti=C5=A1?= Date: Fri, 13 Sep 2024 09:55:27 +0200 Subject: [PATCH 1/2] added threadsafe firing ops, disposable pattern and improved gitignore --- .gitignore | 115 +++++++++++++++++++++++ src/Stateless/StateMachine.Disposing.cs | 42 +++++++++ src/Stateless/StateMachine.Threadsafe.cs | 68 ++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 src/Stateless/StateMachine.Disposing.cs create mode 100644 src/Stateless/StateMachine.Threadsafe.cs diff --git a/.gitignore b/.gitignore index afd3dfdb..0ec975cb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,118 @@ project.lock.json artifacts/ TestResult.xml *.orig + +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- Backup*.rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ diff --git a/src/Stateless/StateMachine.Disposing.cs b/src/Stateless/StateMachine.Disposing.cs new file mode 100644 index 00000000..068b7ca9 --- /dev/null +++ b/src/Stateless/StateMachine.Disposing.cs @@ -0,0 +1,42 @@ +using System; + +namespace Stateless +{ + public partial class StateMachine : IDisposable + { + private bool _disposed = false; + + /// + /// Disposes of the State Machine in-memory resources. View pattern for more details: here + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Disposes of the State Machine in-memory resources. View pattern for more details: here + /// + /// Boolean that indicates whether the method call comes from a Dispose method (its value is true) or from a finalizer (its value is false). + protected void Dispose(bool disposing) + { + if (_disposed) return; + if (disposing) + { + Dispose(); + _disposed = true; + } + + if (disposing) + { + // dispose of managed objects + _semaphore?.Dispose(); + } + + // free unmanaged resources (unmanaged objects) and override a finalizer below. + // set large fields to null. + _disposed = true; + } + } +} \ No newline at end of file diff --git a/src/Stateless/StateMachine.Threadsafe.cs b/src/Stateless/StateMachine.Threadsafe.cs new file mode 100644 index 00000000..d1f05b03 --- /dev/null +++ b/src/Stateless/StateMachine.Threadsafe.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Stateless +{ + public partial class StateMachine + { + private SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); + + /// + /// Fires a trigger in a thread safe mode, so that if there are multiple triggers + /// that have the possibility of being fired at the same time,and the state machine + /// will still ingest them in some order and continue to function normally + /// + /// The trigger that shall be fired in a threadsafe manner. + public async Task FireThreadSafeAsync(TTrigger trigger) + { + await _semaphore.WaitAsync(); + try + { + await FireAsync(trigger); + } + finally + { + _semaphore.Release(); + } + } + + /// + /// Fires a trigger in a thread safe mode, so that if there are multiple triggers + /// that have the possibility of being fired at the same time,and the state machine + /// will still ingest them in some order and continue to function normally + /// + /// The trigger that shall be fired in a threadsafe manner. + /// Parameters associated with the trigger. + public async Task FireThreadSafeAsync(TriggerWithParameters trigger, params object[] args) + { + await _semaphore.WaitAsync(); + try + { + await FireAsync(trigger, args); + } + finally + { + _semaphore.Release(); + } + } + + /// + /// Extension of the State the current TState of the StateMachine in a thread-safe manner + /// + /// the current state of the StateMachine + public async Task GetCurrentStateThreadSafeAsync() + { + await _semaphore.WaitAsync(); + try + { + return State; + } + finally + { + _semaphore.Release(); + } + } + } +} \ No newline at end of file From 3e1840e279990670dba82702249f48d7db745ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iva=20Tuti=C5=A1?= Date: Fri, 13 Sep 2024 10:25:06 +0200 Subject: [PATCH 2/2] added documentation to the threadsafe version --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index 19636b1f..f00b2a63 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,37 @@ await stateMachine.FireAsync(Trigger.Assigned); **Note:** while `StateMachine` may be used _asynchronously_, it remains single-threaded and may not be used _concurrently_ by multiple threads. + +## Calling triggers from multiple threads on the same instance of State Machine (using SM as a shared resource) + +To fire a trigger from a thread safely, the `FireThreadSafeAsync()` method must be used: + +```csharp +await stateMachine.FireThreadSafeAsync(Trigger.Assigned); //the await is entirely optional, you can fire-and-forget +``` + +The state machine itself will remain single-threaded internally, and the firing mechanism invoked by different threads is managed simply using a lock. +The state machine unlocks and will ingest the next trigger only when all actions relating to the firing of the last trigger, such as `PermitDynamic`, `OnEntry` and `OnExit` are executed. + +Calling `FireThreadSafeAsync` from two different threads will therefore result in the two triggers happening sequentially, akin to many siblings coming from all around the house to use the bathroom at the same time - the one that gets in first will finish their business in peace, and the rest will wait in the lobby in front of the loo, essentially forming a queue. +If you do not want to block the firing threads or await the firing process to be over, you can safely just fire-and-forget your triggers from different threads "at" the state machine. + +#### On developing a well-behaving thread-safe state machine + +Example: State machine is in state S, and triggers T1 and T2 can happen at about the same time, from different threads. +Therefore, assuming that T1 would normally lead to a transition from S to S1 state, and accordingly for T2 and S2, it is important to note that there are two ways things can occur: + +```mermaid +graph LR; + +S --[trigger T1 from thread1]--> S1 --[trigger T2 from thread2]--> NEW_STATE1 +S --[trigger T2 from thread2]--> S2 --[trigger T1 from thread1]--> NEW_STATE2 +``` +It is the __duty of the developer__ to handle these occurences, and to define the behaviour of the state machine so that it can mirror the events in the real world. +If any trigger can happen at any time, Stateless may not be the right solution for you. + +I reccomend using `OnUnhandledTrigger` capability mixed with logging to manage race conditions during development, and perhaps introducing a _TState.Panic_ state that the state machine transfers to on the appearance of an unexpected trigger. The latter will disable the default behaviour of `OnUnhandledTrigger` method, which is looping back to the source state. + ## Advanced Features ## ### Retaining the SynchronizationContext ###