Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added the option of firing triggers in a threadsafe manner on the state machine, enabling usage of SM as a shared resource between threads #600

Open
wants to merge 2 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ###
Expand Down
42 changes: 42 additions & 0 deletions src/Stateless/StateMachine.Disposing.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System;

namespace Stateless
{
public partial class StateMachine<TState, TTrigger> : IDisposable
{
private bool _disposed = false;

/// <summary>
/// Disposes of the State Machine in-memory resources. View pattern for more details: <a href="https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-dispose">here</a>
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}

/// <summary>
/// Disposes of the State Machine in-memory resources. View pattern for more details: <a href="https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-dispose">here</a>
/// </summary>
/// <param name="disposing"> Boolean that indicates whether the method call comes from a Dispose method (its value is true) or from a finalizer (its value is false). </param>
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;
}
}
}
68 changes: 68 additions & 0 deletions src/Stateless/StateMachine.Threadsafe.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace Stateless
{
public partial class StateMachine<TState, TTrigger>
{
private SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1);

/// <summary>
/// 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
/// </summary>
/// <param name="trigger">The trigger that shall be fired in a threadsafe manner. </param>
public async Task FireThreadSafeAsync(TTrigger trigger)
{
await _semaphore.WaitAsync();
try
{
await FireAsync(trigger);
}
finally
{
_semaphore.Release();
}
}

/// <summary>
/// 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
/// </summary>
/// <param name="trigger">The trigger that shall be fired in a threadsafe manner. </param>
/// <param name="args"> Parameters associated with the trigger. </param>
public async Task FireThreadSafeAsync(TriggerWithParameters trigger, params object[] args)
{
await _semaphore.WaitAsync();
try
{
await FireAsync(trigger, args);
}
finally
{
_semaphore.Release();
}
}

/// <summary>
/// Extension of the State the current TState of the StateMachine in a thread-safe manner
/// </summary>
/// <returns>the current state of the StateMachine</returns>
public async Task<TState> GetCurrentStateThreadSafeAsync()
{
await _semaphore.WaitAsync();
try
{
return State;
}
finally
{
_semaphore.Release();
}
}
}
}