├── .editorconfig ├── .github ├── CODEOWNERS ├── dependabot.yaml └── workflows │ ├── build-docs.yaml │ ├── build-master.yaml │ ├── build-release.yaml │ ├── stale.yaml │ └── toc.yaml ├── .gitignore ├── Directory.Build.props ├── LICENSE ├── LogicLooper.sln ├── LogicLooper.sln.DotSettings ├── README.ja.md ├── README.md ├── docs ├── .gitignore ├── api │ └── .gitignore ├── docfx.json ├── index.md └── toc.yml ├── samples └── LoopHostingApp │ ├── LifeGameLoop.cs │ ├── LoopHostedService.cs │ ├── LoopHostingApp.csproj │ ├── Pages │ ├── Index.cshtml │ ├── Index.cshtml.cs │ ├── Shared │ │ └── _Layout.cshtml │ ├── _ViewImports.cshtml │ └── _ViewStart.cshtml │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ ├── Startup.cs │ ├── appsettings.Development.json │ └── appsettings.json ├── src └── LogicLooper │ ├── CompilerServices │ ├── LogicLooperCoroutineAsyncValueTaskMethodBuilder.cs │ ├── LogicLooperCoroutineAsyncValueTaskMethodBuilder`1.cs │ └── LogicLooperCoroutineFrameAwaitable.cs │ ├── ILogicLooper.cs │ ├── ILogicLooperPool.cs │ ├── ILogicLooperPoolBalancer.cs │ ├── Icon.png │ ├── Internal │ ├── LogicLooperSynchronizationContext.cs │ ├── MinimumQueue.cs │ ├── NotInitializedLogicLooperPool.cs │ └── SleepInterop.cs │ ├── LogicLooper.cs │ ├── LogicLooper.csproj │ ├── LogicLooperActionContext.cs │ ├── LogicLooperCoroutine.cs │ ├── LogicLooperCoroutineActionContext.cs │ ├── LogicLooperPool.Shared.cs │ ├── LogicLooperPool.cs │ ├── LooperActionOptions.cs │ ├── ManualLogicLooper.cs │ ├── ManualLogicLooperPool.cs │ ├── NativeMethods.txt │ ├── RoundRobinLogicLooperPoolBalancer.cs │ └── opensource.snk └── test └── LogicLooper.Test ├── AssemblyInfo.cs ├── LogicLooper.Test.csproj ├── LogicLooperCoroutineTest.cs ├── LogicLooperPoolTest.cs ├── LogicLooperSynchronizationContextTest.cs ├── LogicLooperTest.cs ├── ManualLogicLooperPoolTest.cs ├── ManualLogicLooperTest.cs ├── SleepInteropTest.cs ├── StressTest.cs └── Usings.cs /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 2 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | # Visual Studio Spell checker configs (https://learn.microsoft.com/en-us/visualstudio/ide/text-spell-checker?view=vs-2022#how-to-customize-the-spell-checker) 13 | spelling_exclusion_path = ./exclusion.dic 14 | 15 | [*.cs] 16 | indent_size = 4 17 | charset = utf-8-bom 18 | end_of_line = unset 19 | 20 | # Solution files 21 | [*.{sln,slnx}] 22 | end_of_line = unset 23 | 24 | # MSBuild project files 25 | [*.{csproj,props,targets}] 26 | end_of_line = unset 27 | 28 | # Xml config files 29 | [*.{ruleset,config,nuspec,resx,runsettings,DotSettings}] 30 | end_of_line = unset 31 | 32 | ############################### 33 | # .NET Coding Conventions # 34 | ############################### 35 | [*.{cs,vb}] 36 | # Organize usings 37 | dotnet_sort_system_directives_first = true 38 | # this. preferences 39 | dotnet_style_qualification_for_field = false:silent 40 | dotnet_style_qualification_for_property = false:silent 41 | dotnet_style_qualification_for_method = false:silent 42 | dotnet_style_qualification_for_event = false:silent 43 | # Language keywords vs BCL types preferences 44 | dotnet_style_predefined_type_for_locals_parameters_members = true:silent 45 | dotnet_style_predefined_type_for_member_access = true:silent 46 | # Parentheses preferences 47 | dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent 48 | dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent 49 | dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent 50 | dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent 51 | # Modifier preferences 52 | dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent 53 | dotnet_style_readonly_field = true:suggestion 54 | # Expression-level preferences 55 | dotnet_style_object_initializer = true:suggestion 56 | dotnet_style_collection_initializer = true:suggestion 57 | dotnet_style_explicit_tuple_names = true:suggestion 58 | dotnet_style_null_propagation = true:suggestion 59 | dotnet_style_coalesce_expression = true:suggestion 60 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true:silent 61 | dotnet_style_prefer_inferred_tuple_names = true:suggestion 62 | dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion 63 | dotnet_style_prefer_auto_properties = true:silent 64 | dotnet_style_prefer_conditional_expression_over_assignment = true:silent 65 | dotnet_style_prefer_conditional_expression_over_return = true:silent 66 | ############################### 67 | # Naming Conventions # 68 | ############################### 69 | # Style Definitions 70 | dotnet_naming_style.pascal_case_style.capitalization = pascal_case 71 | # Use PascalCase for constant fields 72 | dotnet_naming_rule.constant_fields_should_be_pascal_case.severity = suggestion 73 | dotnet_naming_rule.constant_fields_should_be_pascal_case.symbols = constant_fields 74 | dotnet_naming_rule.constant_fields_should_be_pascal_case.style = pascal_case_style 75 | dotnet_naming_symbols.constant_fields.applicable_kinds = field 76 | dotnet_naming_symbols.constant_fields.applicable_accessibilities = * 77 | dotnet_naming_symbols.constant_fields.required_modifiers = const 78 | ############################### 79 | # C# Coding Conventions # 80 | ############################### 81 | [*.cs] 82 | # var preferences 83 | csharp_style_var_for_built_in_types = true:silent 84 | csharp_style_var_when_type_is_apparent = true:silent 85 | csharp_style_var_elsewhere = true:silent 86 | # Expression-bodied members 87 | csharp_style_expression_bodied_methods = false:silent 88 | csharp_style_expression_bodied_constructors = false:silent 89 | csharp_style_expression_bodied_operators = false:silent 90 | csharp_style_expression_bodied_properties = true:silent 91 | csharp_style_expression_bodied_indexers = true:silent 92 | csharp_style_expression_bodied_accessors = true:silent 93 | # Pattern matching preferences 94 | csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion 95 | csharp_style_pattern_matching_over_as_with_null_check = true:suggestion 96 | # Null-checking preferences 97 | csharp_style_throw_expression = true:suggestion 98 | csharp_style_conditional_delegate_call = true:suggestion 99 | # Modifier preferences 100 | csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion 101 | # Expression-level preferences 102 | csharp_prefer_braces = true:silent 103 | csharp_style_deconstructed_variable_declaration = true:suggestion 104 | csharp_prefer_simple_default_expression = true:suggestion 105 | csharp_style_pattern_local_over_anonymous_function = true:suggestion 106 | csharp_style_inlined_variable_declaration = true:suggestion 107 | ############################### 108 | # C# Formatting Rules # 109 | ############################### 110 | # New line preferences 111 | csharp_new_line_before_open_brace = all 112 | csharp_new_line_before_else = true 113 | csharp_new_line_before_catch = true 114 | csharp_new_line_before_finally = true 115 | csharp_new_line_before_members_in_object_initializers = true 116 | csharp_new_line_before_members_in_anonymous_types = true 117 | csharp_new_line_between_query_expression_clauses = true 118 | # Indentation preferences 119 | csharp_indent_case_contents = true 120 | csharp_indent_switch_labels = true 121 | csharp_indent_labels = flush_left 122 | # Space preferences 123 | csharp_space_after_cast = false 124 | csharp_space_after_keywords_in_control_flow_statements = true 125 | csharp_space_between_method_call_parameter_list_parentheses = false 126 | csharp_space_between_method_declaration_parameter_list_parentheses = false 127 | csharp_space_between_parentheses = false 128 | csharp_space_before_colon_in_inheritance_clause = true 129 | csharp_space_after_colon_in_inheritance_clause = true 130 | csharp_space_around_binary_operators = before_and_after 131 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false 132 | csharp_space_between_method_call_name_and_opening_parenthesis = false 133 | csharp_space_between_method_call_empty_parameter_list_parentheses = false 134 | # Wrapping preferences 135 | csharp_preserve_single_line_statements = true 136 | csharp_preserve_single_line_blocks = true 137 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @mayuki 2 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | # ref: https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot 2 | version: 2 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" # Check for updates to GitHub Actions every week 8 | ignore: 9 | # I just want update action when major/minor version is updated. patch updates are too noisy. 10 | - dependency-name: '*' 11 | update-types: 12 | - version-update:semver-patch 13 | -------------------------------------------------------------------------------- /.github/workflows/build-docs.yaml: -------------------------------------------------------------------------------- 1 | name: Build-GitHubPages 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | run-docfx: 10 | if: ${{ !(contains(github.event.head_commit.message, '[skip ci]') || contains(github.event.head_commit.message, '[ci skip]')) }} 11 | permissions: 12 | contents: write 13 | pages: write 14 | runs-on: ubuntu-24.04 15 | timeout-minutes: 10 16 | steps: 17 | - uses: Cysharp/Actions/.github/actions/checkout@main 18 | - uses: Cysharp/Actions/.github/actions/checkout@main 19 | with: 20 | repository: Cysharp/DocfxTemplate 21 | path: docs/_DocfxTemplate 22 | - uses: Kirbyrawr/docfx-action@db9a22c8fe1e8693a2a21be54cb0b87dfaa72cc4 23 | name: Docfx metadata 24 | with: 25 | args: metadata docs/docfx.json 26 | - uses: Kirbyrawr/docfx-action@db9a22c8fe1e8693a2a21be54cb0b87dfaa72cc4 27 | name: Docfx build 28 | with: 29 | args: build docs/docfx.json 30 | - name: Publish to GitHub Pages 31 | uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0 32 | with: 33 | github_token: ${{ secrets.GITHUB_TOKEN }} 34 | publish_dir: docs/_site 35 | -------------------------------------------------------------------------------- /.github/workflows/build-master.yaml: -------------------------------------------------------------------------------- 1 | name: Build-Master 2 | 3 | on: 4 | push: 5 | branches: 6 | - "master" 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build-debug: 13 | name: Build and run tests 14 | permissions: 15 | contents: read 16 | runs-on: ubuntu-24.04 17 | timeout-minutes: 5 18 | steps: 19 | - uses: Cysharp/Actions/.github/actions/checkout@main 20 | - uses: Cysharp/Actions/.github/actions/setup-dotnet@main 21 | - run: dotnet build -c Debug -p:DefineConstants=RUNNING_IN_CI 22 | - run: dotnet pack ./src/LogicLooper/LogicLooper.csproj -c Debug --no-build -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg 23 | - run: dotnet test -c Debug --no-build 24 | - uses: Cysharp/Actions/.github/actions/upload-artifact@main 25 | with: 26 | name: nuget 27 | path: ./src/LogicLooper/bin/Debug/*.*nupkg 28 | retention-days: 1 29 | 30 | test-release-build: 31 | name: Run tests using Release build 32 | needs: [ build-debug ] 33 | strategy: 34 | matrix: 35 | os: [ ubuntu-24.04, windows-2025 ] 36 | permissions: 37 | contents: read 38 | runs-on: ${{ matrix.os }} 39 | timeout-minutes: 10 40 | steps: 41 | - uses: Cysharp/Actions/.github/actions/checkout@main 42 | - uses: Cysharp/Actions/.github/actions/setup-dotnet@main 43 | - run: dotnet build -c Release -p:DefineConstants=RUNNING_IN_CI 44 | - run: dotnet test -c Release --no-build 45 | -------------------------------------------------------------------------------- /.github/workflows/build-release.yaml: -------------------------------------------------------------------------------- 1 | name: Build-Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | tag: 7 | description: "tag: git tag you want create. (sample 1.0.0)" 8 | required: true 9 | dry-run: 10 | description: "dry-run: true will never create release/nuget." 11 | required: true 12 | default: false 13 | type: boolean 14 | 15 | jobs: 16 | build-dotnet: 17 | permissions: 18 | contents: read 19 | runs-on: ubuntu-24.04 20 | timeout-minutes: 5 21 | steps: 22 | - uses: Cysharp/Actions/.github/actions/checkout@main 23 | - uses: Cysharp/Actions/.github/actions/setup-dotnet@main 24 | - run: dotnet build -c Release -p:VersionPrefix=${{ inputs.tag }} -p:DefineConstants=RUNNING_IN_CI 25 | - run: dotnet test -c Release --no-build 26 | - run: dotnet pack -c Release --no-build -p:VersionPrefix=${{ inputs.tag }} -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg -o ./publish 27 | - uses: Cysharp/Actions/.github/actions/upload-artifact@main 28 | with: 29 | name: nuget 30 | path: ./publish 31 | retention-days: 1 32 | 33 | create-release: 34 | needs: [build-dotnet] 35 | permissions: 36 | contents: write 37 | uses: Cysharp/Actions/.github/workflows/create-release.yaml@main 38 | with: 39 | commit-id: ${{ github.sha }} 40 | tag: ${{ inputs.tag }} 41 | dry-run: ${{ inputs.dry-run }} 42 | nuget-push: true 43 | release-upload: false 44 | release-format: '{0}' 45 | secrets: inherit 46 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues" 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" 7 | 8 | jobs: 9 | stale: 10 | permissions: 11 | contents: read 12 | pull-requests: write 13 | issues: write 14 | uses: Cysharp/Actions/.github/workflows/stale-issue.yaml@main 15 | -------------------------------------------------------------------------------- /.github/workflows/toc.yaml: -------------------------------------------------------------------------------- 1 | name: TOC Generator 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'README.md' 7 | 8 | jobs: 9 | toc: 10 | permissions: 11 | contents: write 12 | uses: Cysharp/Actions/.github/workflows/toc-generator.yaml@main 13 | with: 14 | TOC_TITLE: "## Table of Contents" 15 | secrets: inherit 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | 34 | # Visual Studio 2015/2017 cache/options directory 35 | .vs/ 36 | # Uncomment if you have tasks that create the project's static files in wwwroot 37 | #wwwroot/ 38 | 39 | # Visual Studio 2017 auto generated files 40 | Generated\ Files/ 41 | 42 | # MSTest test Results 43 | [Tt]est[Rr]esult*/ 44 | [Bb]uild[Ll]og.* 45 | 46 | # NUnit 47 | *.VisualState.xml 48 | TestResult.xml 49 | nunit-*.xml 50 | 51 | # Build Results of an ATL Project 52 | [Dd]ebugPS/ 53 | [Rr]eleasePS/ 54 | dlldata.c 55 | 56 | # Benchmark Results 57 | BenchmarkDotNet.Artifacts/ 58 | 59 | # .NET Core 60 | project.lock.json 61 | project.fragment.lock.json 62 | artifacts/ 63 | 64 | # StyleCop 65 | StyleCopReport.xml 66 | 67 | # Files built by Visual Studio 68 | *_i.c 69 | *_p.c 70 | *_h.h 71 | *.ilk 72 | *.meta 73 | *.obj 74 | *.iobj 75 | *.pch 76 | *.pdb 77 | *.ipdb 78 | *.pgc 79 | *.pgd 80 | *.rsp 81 | *.sbr 82 | *.tlb 83 | *.tli 84 | *.tlh 85 | *.tmp 86 | *.tmp_proj 87 | *_wpftmp.csproj 88 | *.log 89 | *.vspscc 90 | *.vssscc 91 | .builds 92 | *.pidb 93 | *.svclog 94 | *.scc 95 | 96 | # Chutzpah Test files 97 | _Chutzpah* 98 | 99 | # Visual C++ cache files 100 | ipch/ 101 | *.aps 102 | *.ncb 103 | *.opendb 104 | *.opensdf 105 | *.sdf 106 | *.cachefile 107 | *.VC.db 108 | *.VC.VC.opendb 109 | 110 | # Visual Studio profiler 111 | *.psess 112 | *.vsp 113 | *.vspx 114 | *.sap 115 | 116 | # Visual Studio Trace Files 117 | *.e2e 118 | 119 | # TFS 2012 Local Workspace 120 | $tf/ 121 | 122 | # Guidance Automation Toolkit 123 | *.gpState 124 | 125 | # ReSharper is a .NET coding add-in 126 | _ReSharper*/ 127 | *.[Rr]e[Ss]harper 128 | *.DotSettings.user 129 | 130 | # TeamCity is a build add-in 131 | _TeamCity* 132 | 133 | # DotCover is a Code Coverage Tool 134 | *.dotCover 135 | 136 | # AxoCover is a Code Coverage Tool 137 | .axoCover/* 138 | !.axoCover/settings.json 139 | 140 | # Visual Studio code coverage results 141 | *.coverage 142 | *.coveragexml 143 | 144 | # NCrunch 145 | _NCrunch_* 146 | .*crunch*.local.xml 147 | nCrunchTemp_* 148 | 149 | # MightyMoose 150 | *.mm.* 151 | AutoTest.Net/ 152 | 153 | # Web workbench (sass) 154 | .sass-cache/ 155 | 156 | # Installshield output folder 157 | [Ee]xpress/ 158 | 159 | # DocProject is a documentation generator add-in 160 | DocProject/buildhelp/ 161 | DocProject/Help/*.HxT 162 | DocProject/Help/*.HxC 163 | DocProject/Help/*.hhc 164 | DocProject/Help/*.hhk 165 | DocProject/Help/*.hhp 166 | DocProject/Help/Html2 167 | DocProject/Help/html 168 | 169 | # Click-Once directory 170 | publish/ 171 | 172 | # Publish Web Output 173 | *.[Pp]ublish.xml 174 | *.azurePubxml 175 | # Note: Comment the next line if you want to checkin your web deploy settings, 176 | # but database connection strings (with potential passwords) will be unencrypted 177 | *.pubxml 178 | *.publishproj 179 | 180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 181 | # checkin your Azure Web App publish settings, but sensitive information contained 182 | # in these scripts will be unencrypted 183 | PublishScripts/ 184 | 185 | # NuGet Packages 186 | *.nupkg 187 | # NuGet Symbol Packages 188 | *.snupkg 189 | # The packages folder can be ignored because of Package Restore 190 | **/[Pp]ackages/* 191 | # except build/, which is used as an MSBuild target. 192 | !**/[Pp]ackages/build/ 193 | # Uncomment if necessary however generally it will be regenerated when needed 194 | #!**/[Pp]ackages/repositories.config 195 | # NuGet v3's project.json files produces more ignorable files 196 | *.nuget.props 197 | *.nuget.targets 198 | 199 | # Microsoft Azure Build Output 200 | csx/ 201 | *.build.csdef 202 | 203 | # Microsoft Azure Emulator 204 | ecf/ 205 | rcf/ 206 | 207 | # Windows Store app package directories and files 208 | AppPackages/ 209 | BundleArtifacts/ 210 | Package.StoreAssociation.xml 211 | _pkginfo.txt 212 | *.appx 213 | *.appxbundle 214 | *.appxupload 215 | 216 | # Visual Studio cache files 217 | # files ending in .cache can be ignored 218 | *.[Cc]ache 219 | # but keep track of directories ending in .cache 220 | !?*.[Cc]ache/ 221 | 222 | # Others 223 | ClientBin/ 224 | ~$* 225 | *~ 226 | *.dbmdl 227 | *.dbproj.schemaview 228 | *.jfm 229 | *.pfx 230 | *.publishsettings 231 | orleans.codegen.cs 232 | 233 | # Including strong name files can present a security risk 234 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 235 | #*.snk 236 | 237 | # Since there are multiple workflows, uncomment next line to ignore bower_components 238 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 239 | #bower_components/ 240 | 241 | # RIA/Silverlight projects 242 | Generated_Code/ 243 | 244 | # Backup & report files from converting an old project file 245 | # to a newer Visual Studio version. Backup files are not needed, 246 | # because we have git ;-) 247 | _UpgradeReport_Files/ 248 | Backup*/ 249 | UpgradeLog*.XML 250 | UpgradeLog*.htm 251 | ServiceFabricBackup/ 252 | *.rptproj.bak 253 | 254 | # SQL Server files 255 | *.mdf 256 | *.ldf 257 | *.ndf 258 | 259 | # Business Intelligence projects 260 | *.rdl.data 261 | *.bim.layout 262 | *.bim_*.settings 263 | *.rptproj.rsuser 264 | *- [Bb]ackup.rdl 265 | *- [Bb]ackup ([0-9]).rdl 266 | *- [Bb]ackup ([0-9][0-9]).rdl 267 | 268 | # Microsoft Fakes 269 | FakesAssemblies/ 270 | 271 | # GhostDoc plugin setting file 272 | *.GhostDoc.xml 273 | 274 | # Node.js Tools for Visual Studio 275 | .ntvs_analysis.dat 276 | node_modules/ 277 | 278 | # Visual Studio 6 build log 279 | *.plg 280 | 281 | # Visual Studio 6 workspace options file 282 | *.opt 283 | 284 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 285 | *.vbw 286 | 287 | # Visual Studio LightSwitch build output 288 | **/*.HTMLClient/GeneratedArtifacts 289 | **/*.DesktopClient/GeneratedArtifacts 290 | **/*.DesktopClient/ModelManifest.xml 291 | **/*.Server/GeneratedArtifacts 292 | **/*.Server/ModelManifest.xml 293 | _Pvt_Extensions 294 | 295 | # Paket dependency manager 296 | .paket/paket.exe 297 | paket-files/ 298 | 299 | # FAKE - F# Make 300 | .fake/ 301 | 302 | # CodeRush personal settings 303 | .cr/personal 304 | 305 | # Python Tools for Visual Studio (PTVS) 306 | __pycache__/ 307 | *.pyc 308 | 309 | # Cake - Uncomment if you are using it 310 | # tools/** 311 | # !tools/packages.config 312 | 313 | # Tabs Studio 314 | *.tss 315 | 316 | # Telerik's JustMock configuration file 317 | *.jmconfig 318 | 319 | # BizTalk build output 320 | *.btp.cs 321 | *.btm.cs 322 | *.odx.cs 323 | *.xsd.cs 324 | 325 | # OpenCover UI analysis results 326 | OpenCover/ 327 | 328 | # Azure Stream Analytics local run output 329 | ASALocalRun/ 330 | 331 | # MSBuild Binary and Structured Log 332 | *.binlog 333 | 334 | # NVidia Nsight GPU debugger configuration file 335 | *.nvuser 336 | 337 | # MFractors (Xamarin productivity tool) working folder 338 | .mfractor/ 339 | 340 | # Local History for Visual Studio 341 | .localhistory/ 342 | 343 | # BeatPulse healthcheck temp database 344 | healthchecksdb 345 | 346 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 347 | MigrationBackup/ 348 | 349 | # Ionide (cross platform F# VS Code tools) working folder 350 | .ionide/ 351 | 352 | # IntelliJ IDEA / Rider 353 | .idea/ -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 1.5.0 5 | latest 6 | 7 | 8 | A library for building server application using loop-action programming model on .NET. This library focuses on building game servers with server-side logic. 9 | $(Version) 10 | Cysharp 11 | Cysharp 12 | © Cysharp, Inc. 13 | GameLoop;Framework 14 | https://github.com/Cysharp/LogicLooper 15 | $(PackageProjectUrl) 16 | git 17 | MIT 18 | Icon.png 19 | README.md 20 | 21 | 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Cysharp, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LogicLooper.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31808.319 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LogicLooper", "src\LogicLooper\LogicLooper.csproj", "{E3B6DA2C-B592-44C9-8DD9-F6D3437B338D}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LogicLooper.Test", "test\LogicLooper.Test\LogicLooper.Test.csproj", "{93E5B880-BC22-41F9-9ED1-2A2FF12CED90}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{B37EDC8C-ED5F-4FCC-BD92-460E7C81ECD8}" 11 | ProjectSection(SolutionItems) = preProject 12 | .editorconfig = .editorconfig 13 | .gitignore = .gitignore 14 | .github\workflows\build-master.yaml = .github\workflows\build-master.yaml 15 | .github\workflows\build-release.yml = .github\workflows\build-release.yml 16 | Directory.Build.props = Directory.Build.props 17 | README.ja.md = README.ja.md 18 | README.md = README.md 19 | EndProjectSection 20 | EndProject 21 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Samples", "Samples", "{159ED9FB-ED03-4B01-B22B-3826301E74A6}" 22 | EndProject 23 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LoopHostingApp", "samples\LoopHostingApp\LoopHostingApp.csproj", "{6AFDB90D-8198-45F1-A963-425ED604E24F}" 24 | EndProject 25 | Global 26 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 27 | Debug|Any CPU = Debug|Any CPU 28 | Release|Any CPU = Release|Any CPU 29 | EndGlobalSection 30 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 31 | {E3B6DA2C-B592-44C9-8DD9-F6D3437B338D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 32 | {E3B6DA2C-B592-44C9-8DD9-F6D3437B338D}.Debug|Any CPU.Build.0 = Debug|Any CPU 33 | {E3B6DA2C-B592-44C9-8DD9-F6D3437B338D}.Release|Any CPU.ActiveCfg = Release|Any CPU 34 | {E3B6DA2C-B592-44C9-8DD9-F6D3437B338D}.Release|Any CPU.Build.0 = Release|Any CPU 35 | {93E5B880-BC22-41F9-9ED1-2A2FF12CED90}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 36 | {93E5B880-BC22-41F9-9ED1-2A2FF12CED90}.Debug|Any CPU.Build.0 = Debug|Any CPU 37 | {93E5B880-BC22-41F9-9ED1-2A2FF12CED90}.Release|Any CPU.ActiveCfg = Release|Any CPU 38 | {93E5B880-BC22-41F9-9ED1-2A2FF12CED90}.Release|Any CPU.Build.0 = Release|Any CPU 39 | {6AFDB90D-8198-45F1-A963-425ED604E24F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 40 | {6AFDB90D-8198-45F1-A963-425ED604E24F}.Debug|Any CPU.Build.0 = Debug|Any CPU 41 | {6AFDB90D-8198-45F1-A963-425ED604E24F}.Release|Any CPU.ActiveCfg = Release|Any CPU 42 | {6AFDB90D-8198-45F1-A963-425ED604E24F}.Release|Any CPU.Build.0 = Release|Any CPU 43 | EndGlobalSection 44 | GlobalSection(SolutionProperties) = preSolution 45 | HideSolutionNode = FALSE 46 | EndGlobalSection 47 | GlobalSection(NestedProjects) = preSolution 48 | {6AFDB90D-8198-45F1-A963-425ED604E24F} = {159ED9FB-ED03-4B01-B22B-3826301E74A6} 49 | EndGlobalSection 50 | GlobalSection(ExtensibilityGlobals) = postSolution 51 | SolutionGuid = {BD1813FD-BABB-4330-A7C7-BBAD74B1A666} 52 | EndGlobalSection 53 | EndGlobal 54 | -------------------------------------------------------------------------------- /LogicLooper.sln.DotSettings: -------------------------------------------------------------------------------- 1 | 2 | True 3 | True 4 | True 5 | True 6 | True -------------------------------------------------------------------------------- /README.ja.md: -------------------------------------------------------------------------------- 1 | # LogicLooper 2 | .NET でのサーバーアプリケーションでループを使用したプログラミングモデルを実装するためのライブラリです。 3 | 主にサーバーサイドにロジックがあるゲームサーバーのようなユースケースにフォーカスしています。 4 | 5 | 例えば次のようなゲームループがある場合、これらを集約して素朴な `Task` による駆動よりも効率の良い形で動かす方法を提供します。 6 | 7 | ```csharp 8 | while (true) 9 | { 10 | // 1フレームで行う処理いろいろ 11 | network.Receive(); 12 | world.Update(); 13 | players.Update(); 14 | network.Send(); 15 | // ...他の何か処理 ... 16 | 17 | // 次のフレームを待つ 18 | await Task.Delay(16); 19 | } 20 | ``` 21 | 22 | ```csharp 23 | using var looper = new LogicLooper(60); 24 | await looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 25 | { 26 | // 1フレームで行う処理いろいろ 27 | network.Receive(); 28 | world.Update(); 29 | players.Update(); 30 | network.Send(); 31 | // ...他の何か処理 ... 32 | 33 | return true; // 次のアップデート待ち 34 | }); 35 | ``` 36 | 37 | 38 | 39 | ## Table of Contents 40 | 41 | - [インストール](#%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB) 42 | - [使い方](#%E4%BD%BF%E3%81%84%E6%96%B9) 43 | - [一つのループ](#%E4%B8%80%E3%81%A4%E3%81%AE%E3%83%AB%E3%83%BC%E3%83%97) 44 | - [LooperPool を使用した複数の Looper](#looperpool-%E3%82%92%E4%BD%BF%E7%94%A8%E3%81%97%E3%81%9F%E8%A4%87%E6%95%B0%E3%81%AE-looper) 45 | - [Microsoft.Extensions.Hosting との統合](#microsoftextensionshosting-%E3%81%A8%E3%81%AE%E7%B5%B1%E5%90%88) 46 | - [上級編](#%E4%B8%8A%E7%B4%9A%E7%B7%A8) 47 | - [ユニットテスト / フレーム単位実行](#%E3%83%A6%E3%83%8B%E3%83%83%E3%83%88%E3%83%86%E3%82%B9%E3%83%88--%E3%83%95%E3%83%AC%E3%83%BC%E3%83%A0%E5%8D%98%E4%BD%8D%E5%AE%9F%E8%A1%8C) 48 | - [Coroutine](#coroutine) 49 | - [TargetFrameRateOverride](#targetframerateoverride) 50 | - [Experimental](#experimental) 51 | - [async 対応ループアクション](#async-%E5%AF%BE%E5%BF%9C%E3%83%AB%E3%83%BC%E3%83%97%E3%82%A2%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3) 52 | 53 | 54 | 55 | ## インストール 56 | ```powershell 57 | PS> Install-Package LogicLooper 58 | ``` 59 | ```bash 60 | $ dotnet add package LogicLooper 61 | ``` 62 | 63 | ## 使い方 64 | ### 一つのループ 65 | 一つの Looper は一つのスレッドを占有し、ループを開始します。その Looper に対して複数のループアクションを登録できます。 66 | これはゲームエンジンの一フレームで複数の `Update` メソッドが呼び出されるようなものと似ています。 67 | 68 | ```csharp 69 | using Cysharp.Threading; 70 | 71 | // 指定したフレームレートで Looper を起動します 72 | const int targetFps = 60; 73 | using var looper = new LogicLooper(targetFps); 74 | 75 | // ループのアクションを登録して、完了を待機します 76 | await looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 77 | { 78 | // ループを完了するとき(もう呼ばれる必要がないとき)は `false` を返します 79 | if (...) { return false; } 80 | 81 | // 1フレームの何か処理 ... 82 | 83 | return true; // 次のアップデート待ち 84 | }); 85 | ``` 86 | 87 | ### LooperPool を使用した複数の Looper 88 | 例えば、サーバーに複数のコアがある場合は複数のループとスレッドをホストすることでより効率的に処理を行えます。 89 | `LooperPool` は複数の Looper を束ね、それらの入り口となる API を提供します。この場合 Looper を直接操作する必要がありません。 90 | 91 | ```csharp 92 | using Cysharp.Threading; 93 | 94 | // Looper のプールを作成します 95 | // もし4コアのマシンであれば、LooperPool は4つの Looper のインスタンスを保持します 96 | const int targetFps = 60; 97 | var looperCount = Environment.ProcessorCount; 98 | using var looperPool = new LogicLooperPool(targetFps, looperCount, RoundRobinLogicLooperPoolBalancer.Instance); 99 | 100 | // ループのアクションを登録して、完了を待機します 101 | await looperPool.RegisterActionAsync((in LogicLooperActionContext ctx) => 102 | { 103 | // ループを完了するとき(もう呼ばれる必要がないとき)は `false` を返します 104 | if (...) { return false; } 105 | 106 | // 1フレームの何か処理 ... 107 | 108 | return true; // 次のアップデート待ち 109 | }); 110 | ``` 111 | 112 | ### Microsoft.Extensions.Hosting との統合 113 | [samples/LoopHostingApp](samples/LoopHostingApp) をご覧ください。`IHostedService` と組み合わせることでサーバーのライフサイクルなどを考慮した実装を行えます。 114 | 115 | ## 上級編 116 | ### ユニットテスト / フレーム単位実行 117 | LogicLooper と共にユニットテストを記述する場合やフレームを手動で更新したいような場合には `ManualLogicLooper` / `ManualLogicLooperPool` を利用できます。 118 | 119 | ```csharp 120 | var looper = new ManualLogicLooper(60.0); // `ElapsedTimeFromPreviousFrame` は `1000 / FrameTargetFrameRate` に固定されます 121 | 122 | var count = 0; 123 | var t1 = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 124 | { 125 | count++; 126 | return count != 3; 127 | }); 128 | 129 | looper.Tick(); // Update frame 130 | Console.WriteLine(count); // => 1 131 | 132 | looper.Tick(); // Update frame 133 | Console.WriteLine(count); // => 2 134 | 135 | looper.Tick(); // Update frame (t1 が完了します) 136 | Console.WriteLine(count); // => 3 137 | 138 | looper.Tick(); // Update frame (実行するアクションはありません) 139 | Console.WriteLine(count); // => 3 140 | ``` 141 | 142 | ### Coroutine 143 | LogicLooper はコルーチンのような操作もサポートしています。Unity を利用したことがあればなじみのあるコルーチンパターンです。 144 | 145 | ```csharp 146 | using var looper = new LogicLooper(60); 147 | 148 | var coroutine = default(LogicLooperCoroutine); 149 | await looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 150 | { 151 | if (/* ... */) 152 | { 153 | // コルーチンを現在のループアクションが実行されている Looper で起動する 154 | coroutine = ctx.RunCoroutine(async coCtx => 155 | { 156 | // NOTE: コルーチンの中では `DelayFrame`, `DelayNextFrame`, `Delay` メソッドのみを待機(`await`)可能です 157 | // もし `Task` や `Task-like` をコルーチン内で `await` した場合、例外を送出します 158 | await coCtx.DelayFrame(60); // 60フレームを待つ 159 | 160 | // 何か処理 … 161 | 162 | await coCtx.DelayNextFrame(); // 次のフレームを待つ 163 | 164 | // 何か処理 … 165 | 166 | await coCtx.Delay(TimeSpan.FromMilliseconds(16.66666)); // 約16ミリ秒待つ (=1f) 167 | }); 168 | } 169 | 170 | // コルーチンが終わったかどうかをチェックする 171 | if (coroutine != null && coroutine.IsCompleted) 172 | { 173 | // コルーチンが終わった場合、何か処理する… 174 | } 175 | 176 | return true; 177 | }); 178 | ``` 179 | 180 | ### TargetFrameRateOverride 181 | 182 | アクションごとにフレームレートのオーバーライドが可能です。これは例えば大元のループは 30fps で動くことを期待しながらも、一部のアクションは 5fps で呼び出されてほしいといった複数のフレームレートを混在させたいケースで役立ちます。 183 | ループを実行する Looper ごとにフレームレートを設定することもできますが LogicLooper のデザインは1ループ1スレッドとなっているため、原則としてコア数に準じた Looper 数を期待しています。アクションごとにフレームレートを設定することでワークロードが変化する場合でも Looper の数を固定できます。 184 | 185 | ```csharp 186 | using var looper = new LogicLooper(60); // 60 fps でループは実行する 187 | 188 | await looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 189 | { 190 | // Something to do ... 191 | return true; 192 | }); // 60 fps で呼び出される 193 | 194 | 195 | await looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 196 | { 197 | // Something to do (低頻度) ... 198 | return true; 199 | }, LooperActionOptions.Default with { TargetFrameRateOverride = 10 }); // 10 fps で呼び出される 200 | ``` 201 | 202 | 注意点として大元のループ自体の実行頻度によってアクションの実行粒度が変わります。これは Looper のターゲットフレームレートよりも正確性が劣ることがあるということを意味します。 203 | 204 | ## Experimental 205 | ### async 対応ループアクション 206 | ループアクションで非同期イベントを待機できる試験的なサポートを提供します。 207 | 208 | SynchronizationContext を利用して、すべての非同期メソッドの継続はループスレッド上で実行されます。しかし非同期を待機するアクションは同期的に完了するアクションと異なり複数のフレームにまたがって実行されることに注意が必要です。 209 | 210 | ```csharp 211 | await looper.RegisterActionAsync(static async (ctx, state) => 212 | { 213 | state.Add("1"); // Frame: 1 214 | await Task.Delay(250); 215 | state.Add("2"); // Frame: 2 or later 216 | return false; 217 | }); 218 | ``` 219 | 220 | > [!WARNING] 221 | > もしアクションが同期的に完了する場合 (つまり非同期メソッドであっても `ValueTask.IsCompleted = true` となる状況)、async ではないバージョンとパフォーマンスの差はありません。しかし await して非同期処理に対して継続実行する必要がある場合にはとても低速になります。この非同期サポートは低頻度の外部との通信を目的とした緊急ハッチ的な役割を提供します。高頻度での非同期処理を実行することは強く非推奨です。 222 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [](https://github.com/Cysharp/LogicLooper/actions/workflows/build-master.yaml) [](https://github.com/Cysharp/LogicLooper/releases) 2 | 3 | # LogicLooper 4 | 5 | [日本語](README.ja.md) 6 | 7 | A library is for building server application using loop-action programming model on .NET. This library focuses on building game servers with server-side logic. 8 | 9 | For example, if you have the following game loops, the library will provide a way to aggregate and process in a more efficient way than driving with a simple `Task`. 10 | 11 | ```csharp 12 | while (true) 13 | { 14 | // some stuff to do ... 15 | network.Receive(); 16 | world.Update(); 17 | players.Update(); 18 | network.Send(); 19 | // some stuff to do ... 20 | 21 | // wait for next frame 22 | await Task.Delay(16); 23 | } 24 | ``` 25 | 26 | ```csharp 27 | using var looper = new LogicLooper(60); 28 | await looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 29 | { 30 | // The action will be called by looper every frame. 31 | // some stuff to do ... 32 | network.Receive(); 33 | world.Update(); 34 | players.Update(); 35 | network.Send(); 36 | // some stuff to do ... 37 | 38 | return true; // wait for next update 39 | }); 40 | ``` 41 | 42 | 43 | 44 | ## Table of Contents 45 | 46 | - [Installation](#installation) 47 | - [Usage](#usage) 48 | - [Single-loop application](#single-loop-application) 49 | - [Multiple-loop application using LooperPool](#multiple-loop-application-using-looperpool) 50 | - [Integrate with Microsoft.Extensions.Hosting](#integrate-with-microsoftextensionshosting) 51 | - [Advanced](#advanced) 52 | - [Unit tests / Frame-by-Frame execution](#unit-tests--frame-by-frame-execution) 53 | - [Coroutine](#coroutine) 54 | - [TargetFrameRateOverride](#targetframerateoverride) 55 | - [Experimental](#experimental) 56 | - [async-aware loop actions](#async-aware-loop-actions) 57 | 58 | 59 | 60 | ## Installation 61 | ```powershell 62 | PS> Install-Package LogicLooper 63 | ``` 64 | ```bash 65 | $ dotnet add package LogicLooper 66 | ``` 67 | 68 | ## Usage 69 | ### Single-loop application 70 | A Looper bound one thread and begin a main-loop. You can register multiple loop actions for the Looper. 71 | It's similar to be multiple `Update` methods called in one frame of the game engine. 72 | 73 | ```csharp 74 | using Cysharp.Threading; 75 | 76 | // Create a looper. 77 | const int targetFps = 60; 78 | using var looper = new LogicLooper(targetFps); 79 | 80 | // Register a action to the looper and wait for completion. 81 | await looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 82 | { 83 | // If you want to stop/complete the loop, return false to stop. 84 | if (...) { return false; } 85 | 86 | // some stuff to do ... 87 | 88 | return true; // wait for a next update. 89 | }); 90 | ``` 91 | 92 | ### Multiple-loop application using LooperPool 93 | For example, if your server has many cores, it is more efficient running multiple loops. `LooperPool` provides multiple loopers and facade for using them. 94 | 95 | ```csharp 96 | using Cysharp.Threading; 97 | 98 | // Create a looper pool. 99 | // If your machine has 4-cores, the LooperPool creates 4-Looper instances. 100 | const int targetFps = 60; 101 | var looperCount = Environment.ProcessorCount; 102 | using var looperPool = new LogicLooperPool(targetFps, looperCount, RoundRobinLogicLooperPoolBalancer.Instance); 103 | 104 | // Register a action to the looper and wait for completion. 105 | await looperPool.RegisterActionAsync((in LogicLooperActionContext ctx) => 106 | { 107 | // If you want to stop/complete the loop, return false to stop. 108 | if (...) { return false; } 109 | 110 | // some stuff to do ... 111 | 112 | return true; // wait for a next update. 113 | }); 114 | ``` 115 | 116 | ### Integrate with Microsoft.Extensions.Hosting 117 | See [samples/LoopHostingApp](samples/LoopHostingApp). 118 | 119 | ## Advanced 120 | ### Unit tests / Frame-by-Frame execution 121 | If you want to write unit tests with LogicLooper or update frames manually, you can use `ManualLogicLooper` / `ManualLogicLooperPool`. 122 | 123 | ```csharp 124 | var looper = new ManualLogicLooper(60.0); // `ElapsedTimeFromPreviousFrame` will be fixed to `1000 / FrameTargetFrameRate`. 125 | 126 | var count = 0; 127 | var t1 = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 128 | { 129 | count++; 130 | return count != 3; 131 | }); 132 | 133 | looper.Tick(); // Update frame 134 | Console.WriteLine(count); // => 1 135 | 136 | looper.Tick(); // Update frame 137 | Console.WriteLine(count); // => 2 138 | 139 | looper.Tick(); // Update frame (t1 will be completed) 140 | Console.WriteLine(count); // => 3 141 | 142 | looper.Tick(); // Update frame (no action) 143 | Console.WriteLine(count); // => 3 144 | ``` 145 | 146 | ### Coroutine 147 | LogicLooper has support for the coroutine-like operation. If you are using Unity, you are familiar with the coroutine pattern. 148 | 149 | ```csharp 150 | using var looper = new LogicLooper(60); 151 | 152 | var coroutine = default(LogicLooperCoroutine); 153 | await looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 154 | { 155 | if (/* ... */) 156 | { 157 | // Launch a coroutine in the looper that same as the loop action. 158 | coroutine = ctx.RunCoroutine(async coCtx => 159 | { 160 | // NOTE: `DelayFrame`, `DelayNextFrame`, `Delay` methods are allowed and awaitable in the coroutine. 161 | // If you await a Task or Task-like, the coroutine throws an exception. 162 | await coCtx.DelayFrame(60); 163 | 164 | // some stuff to do ... 165 | 166 | await coCtx.DelayNextFrame(); 167 | 168 | // some stuff to do ... 169 | 170 | await coCtx.Delay(TimeSpan.FromMilliseconds(16.66666)); 171 | }); 172 | } 173 | 174 | if (coroutine.IsCompleted) 175 | { 176 | // When the coroutine has completed, you can do some stuff ... 177 | } 178 | 179 | return true; 180 | }); 181 | ``` 182 | 183 | ### TargetFrameRateOverride 184 | 185 | `TargetFrameRateOverride` option allows to override the frame rate for each action. This can be useful in cases where you want to mix multiple frame rates, such as expecting the main loop to run at 30fps, but wanting some actions to be called at 5fps. 186 | 187 | You can also set the frame rate for each Looper that executes the loops, but the design of LogicLooper is 1-loop per thread, so in principle we expect a number of Loopers in accordance with the number of cores. By setting the frame rate for each action, you can keep the number of Loopers fixed even if the workload changes. 188 | 189 | ```csharp 190 | using var looper = new LogicLooper(60); // 60 fps 191 | 192 | await looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 193 | { 194 | // Something to do ... 195 | return true; 196 | }); // The action will be called at 60fps. 197 | 198 | await looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 199 | { 200 | // Something to do (low priority) ... 201 | return true; 202 | }, LooperActionOptions.Default with { TargetFrameRateOverride = 10 }); // The action will be called at 10fps. 203 | ``` 204 | 205 | The granularity of action execution changes based on the execution frequency of the main loop itself. This means that the accuracy may be inferior to the target frame rate of the Looper. 206 | 207 | ## Experimental 208 | ### async-aware loop actions 209 | Experimental support for loop actions that can await asynchronous events. 210 | 211 | With SynchronizationContext, all asynchronous continuations are executed on the loop thread. 212 | Please beware that asynchronous actions are executed across multiple frames, unlike synchronous actions. 213 | 214 | ```csharp 215 | await looper.RegisterActionAsync(static async (ctx, state) => 216 | { 217 | state.Add("1"); // Frame: 1 218 | await Task.Delay(250); 219 | state.Add("2"); // Frame: 2 or later 220 | return false; 221 | }); 222 | ``` 223 | 224 | > [!WARNING] 225 | > If an action completes immediately (`ValueTask.IsCompleted = true`), there's no performance difference from non-async version. But it is very slow if there's a need to await. This asynchronous support provides as an emergency hatch when it becomes necessary to communicate with the outside at a low frequency. We do not recommended to perform asynchronous processing at a high frequency. 226 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | ############### 2 | # folder # 3 | ############### 4 | /**/DROP/ 5 | /**/TEMP/ 6 | /**/packages/ 7 | /**/bin/ 8 | /**/obj/ 9 | _site 10 | _DocfxTemplate -------------------------------------------------------------------------------- /docs/api/.gitignore: -------------------------------------------------------------------------------- 1 | ############### 2 | # temp file # 3 | ############### 4 | *.yml 5 | .manifest 6 | -------------------------------------------------------------------------------- /docs/docfx.json: -------------------------------------------------------------------------------- 1 | { 2 | "metadata": [ 3 | { 4 | "src": [ 5 | { 6 | "files": [ 7 | "**/*.csproj" 8 | ], 9 | "src": "../src" 10 | } 11 | ], 12 | "dest": "api", 13 | "disableGitFeatures": false, 14 | "disableDefaultFilter": false 15 | } 16 | ], 17 | "build": { 18 | "globalMetadata": { 19 | "_disableContribution": true, 20 | "_appTitle": "LogicLooper" 21 | }, 22 | "content": [ 23 | { 24 | "files": [ 25 | "api/**.yml", 26 | "api/index.md" 27 | ] 28 | }, 29 | { 30 | "files": [ 31 | "articles/**.md", 32 | "articles/**/toc.yml", 33 | "toc.yml", 34 | "*.md" 35 | ] 36 | } 37 | ], 38 | "resource": [ 39 | { 40 | "files": [ 41 | "images/**" 42 | ] 43 | } 44 | ], 45 | "overwrite": [ 46 | { 47 | "files": [ 48 | "apidoc/**.md" 49 | ], 50 | "exclude": [ 51 | "obj/**", 52 | "_site/**" 53 | ] 54 | } 55 | ], 56 | "dest": "_site", 57 | 58 | "globalMetadataFiles": [], 59 | "fileMetadataFiles": [], 60 | "template": [ 61 | "_DocfxTemplate/templates/default-v2.5.2", 62 | "_DocfxTemplate/templates/cysharp" 63 | ], 64 | "postProcessors": [], 65 | "markdownEngineName": "markdig", 66 | "noLangKeyword": false, 67 | "keepFileLink": false, 68 | "cleanupCacheHistory": false 69 | } 70 | } -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Home 3 | --- 4 | # LogicLooper 5 | A library is for building server application using loop-action programming model on .NET Core. This library focuses on building game servers with server-side logic. 6 | 7 | For example, if you have the following game loops, the library will provide a way to aggregate and process in a more efficient way than driving with a simple Task. -------------------------------------------------------------------------------- /docs/toc.yml: -------------------------------------------------------------------------------- 1 | - name: API Documentation 2 | href: api/ 3 | homepage: api/Cysharp.Threading.html 4 | - name: Repository 5 | href: https://github.com/Cysharp/LogicLooper 6 | homepage: https://github.com/Cysharp/LogicLooper -------------------------------------------------------------------------------- /samples/LoopHostingApp/LifeGameLoop.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Text; 5 | using System.Threading; 6 | using Cysharp.Threading; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace LoopHostingApp 10 | { 11 | public class LifeGameLoop 12 | { 13 | private static int _gameLoopSeq = 0; 14 | public static ConcurrentBag All { get; } = new ConcurrentBag(); 15 | 16 | private readonly ILogger _logger; 17 | 18 | public World World { get; } 19 | public int Id { get; } 20 | 21 | /// 22 | /// Create a new life-game loop and register into the LooperPool. 23 | /// 24 | /// 25 | /// 26 | public static void CreateNew(ILogicLooperPool looperPool, ILogger logger) 27 | { 28 | var gameLoop = new LifeGameLoop(logger); 29 | looperPool.RegisterActionAsync(gameLoop.UpdateFrame); 30 | } 31 | 32 | private LifeGameLoop(ILogger logger) 33 | { 34 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 35 | 36 | Id = Interlocked.Increment(ref _gameLoopSeq); 37 | World = new World(64, 64); 38 | World.SetPattern(Patterns.GliderGun, 10, 10); 39 | 40 | _logger.LogInformation($"{nameof(LifeGameLoop)}[{Id}]: Register"); 41 | 42 | All.Add(this); 43 | } 44 | 45 | public bool UpdateFrame(in LogicLooperActionContext ctx) 46 | { 47 | if (ctx.CancellationToken.IsCancellationRequested) 48 | { 49 | // If LooperPool begins shutting down, IsCancellationRequested will be `true`. 50 | _logger.LogInformation($"{nameof(LifeGameLoop)}[{Id}]: Shutdown"); 51 | return false; 52 | } 53 | 54 | // Update the world every update cycle. 55 | World.Update(); 56 | 57 | return World.AliveCount != 0; 58 | } 59 | } 60 | 61 | public static class Patterns 62 | { 63 | // glider pattern 64 | public static readonly int[,] Glider = new[,] 65 | { 66 | { 1, 1, 1 }, 67 | { 1, 0, 0 }, 68 | { 0, 1, 0 } 69 | }; 70 | 71 | public static readonly int[,] GliderGun = new[,] 72 | { 73 | { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 74 | { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 75 | { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 76 | { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0 }, 77 | { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0 }, 78 | { 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 79 | { 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 80 | { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 81 | { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 82 | { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 83 | { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 84 | }; 85 | } 86 | 87 | public class World 88 | { 89 | private readonly Cell[] _cells; 90 | private readonly int _width; 91 | private readonly int _height; 92 | 93 | public bool[,] Snapshot { get; private set; } 94 | 95 | public int AliveCount { get; private set; } 96 | 97 | public World(int width, int height) 98 | { 99 | _width = width; 100 | _height = height; 101 | _cells = new Cell[width * height]; 102 | 103 | for (var x = 0; x < width; x++) 104 | { 105 | for (var y = 0; y < height; y++) 106 | { 107 | _cells[x + (y * width)] = new Cell(); 108 | } 109 | } 110 | } 111 | 112 | public void Set(int x, int y, bool isAlive) 113 | { 114 | if (!TryGetCell(x, y, out var cell)) 115 | { 116 | throw new ArgumentOutOfRangeException(); 117 | } 118 | 119 | cell.IsAlive = isAlive; 120 | } 121 | 122 | public void SetPattern(int[,] pattern, int offsetX, int offsetY) 123 | { 124 | for (var x = 0; x <= pattern.GetUpperBound(0); x++) 125 | { 126 | for (var y = 0; y <= pattern.GetUpperBound(1); y++) 127 | { 128 | Set(offsetX + x, offsetY + y, pattern[x, y] == 1); 129 | } 130 | } 131 | } 132 | 133 | public void Update() 134 | { 135 | var count = 0; 136 | for (var y = 0; y < _height; y++) 137 | { 138 | for (var x = 0; x < _width; x++) 139 | { 140 | if (TryGetCell(x, y, out var cell)) 141 | { 142 | cell.NestState = GetCellNextState(x, y) switch 143 | { 144 | CellState.Alive => true, 145 | CellState.Dead => false, 146 | CellState.Remain => cell.IsAlive, 147 | _ => throw new ArgumentOutOfRangeException() 148 | }; 149 | } 150 | } 151 | } 152 | 153 | // Apply new state and take a snapshot. 154 | var snapshot = new bool[_width, _height]; 155 | for (var y = 0; y < _height; y++) 156 | { 157 | for (var x = 0; x < _width; x++) 158 | { 159 | if (TryGetCell(x, y, out var cell)) 160 | { 161 | cell.IsAlive = cell.NestState; 162 | if (cell.IsAlive) 163 | { 164 | count++; 165 | } 166 | 167 | snapshot[x, y] = cell.IsAlive; 168 | } 169 | } 170 | } 171 | 172 | AliveCount = count; 173 | Snapshot = snapshot; 174 | } 175 | 176 | private enum CellState 177 | { 178 | Alive, 179 | Dead, 180 | Remain, 181 | } 182 | 183 | private CellState GetCellNextState(int x, int y) 184 | { 185 | var livingCellsCount = 0; 186 | for (var x2 = x - 1; x2 <= x + 1; x2++) 187 | { 188 | for (var y2 = y - 1; y2 <= y + 1; y2++) 189 | { 190 | if (x2 == x && y2 == y) continue; 191 | 192 | if (TryGetCell(x2, y2, out var cell) && cell.IsAlive) 193 | { 194 | livingCellsCount++; 195 | } 196 | } 197 | } 198 | 199 | return livingCellsCount == 2 200 | ? CellState.Remain 201 | : livingCellsCount == 3 202 | ? CellState.Alive 203 | : CellState.Dead; 204 | } 205 | 206 | private bool TryGetCell(int x, int y, out Cell cell) 207 | { 208 | if (x >= _width || y >= _height || x < 0 || y < 0) 209 | { 210 | cell = null; 211 | return false; 212 | } 213 | 214 | cell = _cells[x + (y * _width)]; 215 | 216 | return true; 217 | } 218 | 219 | public override string ToString() 220 | { 221 | var sb = new StringBuilder(); 222 | for (var y = 0; y < _height; y++) 223 | { 224 | for (var x = 0; x < _width; x++) 225 | { 226 | if (TryGetCell(x, y, out var cell)) 227 | { 228 | sb.Append(cell.IsAlive ? "x" : "."); 229 | } 230 | } 231 | sb.AppendLine(); 232 | } 233 | 234 | return sb.ToString(); 235 | } 236 | 237 | public void Dump() 238 | { 239 | Console.WriteLine(Snapshot); 240 | Console.WriteLine(); 241 | } 242 | 243 | public class Cell 244 | { 245 | public bool IsAlive { get; set; } 246 | public bool NestState { get; set; } 247 | } 248 | } 249 | 250 | } 251 | -------------------------------------------------------------------------------- /samples/LoopHostingApp/LoopHostedService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Cysharp.Threading; 6 | using Microsoft.Extensions.Hosting; 7 | using Microsoft.Extensions.Logging; 8 | 9 | namespace LoopHostingApp 10 | { 11 | class LoopHostedService : IHostedService 12 | { 13 | private readonly ILogicLooperPool _looperPool; 14 | private readonly ILogger _logger; 15 | 16 | public LoopHostedService(ILogicLooperPool looperPool, ILogger logger) 17 | { 18 | _looperPool = looperPool ?? throw new ArgumentNullException(nameof(looperPool)); 19 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 20 | } 21 | 22 | public Task StartAsync(CancellationToken cancellationToken) 23 | { 24 | // Example: Register update action immediately. 25 | _ = _looperPool.RegisterActionAsync((in LogicLooperActionContext ctx) => 26 | { 27 | if (ctx.CancellationToken.IsCancellationRequested) 28 | { 29 | // If LooperPool begins shutting down, IsCancellationRequested will be `true`. 30 | _logger.LogInformation("LoopHostedService will be shutdown soon. The registered action is shutting down gracefully."); 31 | return false; 32 | } 33 | 34 | return true; 35 | }); 36 | 37 | // Example: Create a new world of life-game and register it into the loop. 38 | // - See also: LoopHostingApp/Pages/Index.cshtml.cs 39 | LifeGameLoop.CreateNew(_looperPool, _logger); 40 | 41 | _logger.LogInformation($"LoopHostedService is started. (Loopers={_looperPool.Loopers.Count}; TargetFrameRate={_looperPool.Loopers[0].TargetFrameRate:0}fps)"); 42 | 43 | return Task.CompletedTask; 44 | } 45 | 46 | public async Task StopAsync(CancellationToken cancellationToken) 47 | { 48 | _logger.LogInformation("LoopHostedService is shutting down. Waiting for loops."); 49 | 50 | // Shutdown gracefully the LooperPool after 5 seconds. 51 | await _looperPool.ShutdownAsync(TimeSpan.FromSeconds(5)); 52 | 53 | // Count remained actions in the LooperPool. 54 | var remainedActions = _looperPool.Loopers.Sum(x => x.ApproximatelyRunningActions); 55 | _logger.LogInformation($"{remainedActions} actions are remained in loop."); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /samples/LoopHostingApp/LoopHostingApp.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | false 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /samples/LoopHostingApp/Pages/Index.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model IndexModel 3 | @{ 4 | ViewData["Title"] = "Home page"; 5 | } 6 | @section Head 7 | { 8 | 25 | } 26 | 27 | 28 | RunningActions: @Model.RunningActions 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | Worlds 42 | @foreach (var world in Model.RunningWorlds) 43 | { 44 | var snapshot = world.Snapshot; 45 | if (snapshot == null) continue; 46 | 47 | 48 | 49 | @for (var y = 0; y <= snapshot.GetUpperBound(1); y++) 50 | { 51 | 52 | @for (var x = 0; x <= snapshot.GetUpperBound(0); x++) 53 | { 54 | 55 | } 56 | 57 | } 58 | 59 | 60 | } 61 | -------------------------------------------------------------------------------- /samples/LoopHostingApp/Pages/Index.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Cysharp.Threading; 6 | using Microsoft.AspNetCore.Mvc; 7 | using Microsoft.AspNetCore.Mvc.RazorPages; 8 | using Microsoft.Extensions.Logging; 9 | 10 | namespace LoopHostingApp.Pages 11 | { 12 | public class IndexModel : PageModel 13 | { 14 | private readonly ILogger _logger; 15 | private readonly ILogicLooperPool _looperPool; 16 | 17 | public int RunningActions => _looperPool.Loopers.Sum(x => x.ApproximatelyRunningActions); 18 | public IReadOnlyList RunningWorlds => LifeGameLoop.All.Select(x => x.World).ToArray(); 19 | 20 | public IndexModel(ILogger logger, ILogicLooperPool looperPool) 21 | { 22 | // The parameter is provided via Dependency Injection. 23 | // - See also: Startup.ConfigureServices method. 24 | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); 25 | _looperPool = looperPool ?? throw new ArgumentNullException(nameof(looperPool)); 26 | } 27 | 28 | public void OnGet() 29 | { 30 | } 31 | 32 | public IActionResult OnPost() 33 | { 34 | // Example: Create a new world of life-game and register it into the loop. 35 | LifeGameLoop.CreateNew(_looperPool, _logger); 36 | 37 | return RedirectToPage("Index"); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /samples/LoopHostingApp/Pages/Shared/_Layout.cshtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | @ViewData["Title"] - LoopHostingApp 7 | @RenderSection("Head", required: false) 8 | 9 | 10 | 11 | 12 | @RenderBody() 13 | 14 | 15 | 16 | @RenderSection("Scripts", required: false) 17 | 18 | 19 | -------------------------------------------------------------------------------- /samples/LoopHostingApp/Pages/_ViewImports.cshtml: -------------------------------------------------------------------------------- 1 | @using LoopHostingApp 2 | @namespace LoopHostingApp.Pages 3 | @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 4 | -------------------------------------------------------------------------------- /samples/LoopHostingApp/Pages/_ViewStart.cshtml: -------------------------------------------------------------------------------- 1 | @{ 2 | Layout = "_Layout"; 3 | } 4 | -------------------------------------------------------------------------------- /samples/LoopHostingApp/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Hosting; 9 | using Microsoft.Extensions.Logging; 10 | 11 | namespace LoopHostingApp 12 | { 13 | public class Program 14 | { 15 | public static void Main(string[] args) 16 | { 17 | CreateHostBuilder(args).Build().Run(); 18 | } 19 | 20 | public static IHostBuilder CreateHostBuilder(string[] args) => 21 | Host.CreateDefaultBuilder(args) 22 | .ConfigureServices(services => 23 | { 24 | services.Configure(options => 25 | { 26 | options.ShutdownTimeout = TimeSpan.FromSeconds(10); 27 | }); 28 | }) 29 | .ConfigureWebHostDefaults(webBuilder => 30 | { 31 | webBuilder.UseStartup(); 32 | }); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /samples/LoopHostingApp/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "LoopHostingApp": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 7 | "environmentVariables": { 8 | "ASPNETCORE_ENVIRONMENT": "Development" 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /samples/LoopHostingApp/Startup.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Cysharp.Threading; 6 | using Microsoft.AspNetCore.Builder; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.AspNetCore.HttpsPolicy; 9 | using Microsoft.Extensions.Configuration; 10 | using Microsoft.Extensions.DependencyInjection; 11 | using Microsoft.Extensions.Hosting; 12 | 13 | namespace LoopHostingApp 14 | { 15 | public class Startup 16 | { 17 | public Startup(IConfiguration configuration) 18 | { 19 | Configuration = configuration; 20 | } 21 | 22 | public IConfiguration Configuration { get; } 23 | 24 | // This method gets called by the runtime. Use this method to add services to the container. 25 | public void ConfigureServices(IServiceCollection services) 26 | { 27 | // Register a LooperPool to the service container. 28 | services.AddSingleton(_ => new LogicLooperPool(10, Environment.ProcessorCount, RoundRobinLogicLooperPoolBalancer.Instance)); 29 | services.AddHostedService(); 30 | 31 | services.AddRazorPages(); 32 | } 33 | 34 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. 35 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 36 | { 37 | if (env.IsDevelopment()) 38 | { 39 | app.UseDeveloperExceptionPage(); 40 | } 41 | else 42 | { 43 | app.UseExceptionHandler("/Error"); 44 | // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. 45 | app.UseHsts(); 46 | } 47 | 48 | app.UseHttpsRedirection(); 49 | app.UseStaticFiles(); 50 | 51 | app.UseRouting(); 52 | 53 | app.UseAuthorization(); 54 | 55 | app.UseEndpoints(endpoints => 56 | { 57 | endpoints.MapRazorPages(); 58 | }); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /samples/LoopHostingApp/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /samples/LoopHostingApp/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "AllowedHosts": "*" 10 | } 11 | -------------------------------------------------------------------------------- /src/LogicLooper/CompilerServices/LogicLooperCoroutineAsyncValueTaskMethodBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.Runtime.CompilerServices; 3 | 4 | namespace Cysharp.Threading.CompilerServices; 5 | 6 | #if !DEBUG 7 | [EditorBrowsable(EditorBrowsableState.Never)] 8 | #endif 9 | public struct LogicLooperCoroutineAsyncValueTaskMethodBuilder 10 | { 11 | private readonly LogicLooperCoroutine _coroutine; 12 | 13 | public static LogicLooperCoroutineAsyncValueTaskMethodBuilder Create() 14 | { 15 | return new LogicLooperCoroutineAsyncValueTaskMethodBuilder(new LogicLooperCoroutine(LogicLooperCoroutineActionContext.Current!)); 16 | } 17 | 18 | private LogicLooperCoroutineAsyncValueTaskMethodBuilder(LogicLooperCoroutine coroutine) 19 | { 20 | _coroutine = coroutine ?? throw new ArgumentNullException(nameof(coroutine)); 21 | } 22 | 23 | public void Start(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine 24 | { 25 | stateMachine.MoveNext(); 26 | } 27 | 28 | public void SetStateMachine(IAsyncStateMachine stateMachine) 29 | { 30 | } 31 | 32 | public void SetResult() 33 | { 34 | _coroutine.SetResult(); 35 | } 36 | 37 | public void SetException(Exception exception) 38 | { 39 | _coroutine.SetException(exception); 40 | } 41 | 42 | public LogicLooperCoroutine Task => _coroutine; 43 | 44 | public void AwaitOnCompleted(ref TAwaiter awaiter, ref TStateMachine stateMachine) 45 | where TAwaiter : INotifyCompletion 46 | where TStateMachine : IAsyncStateMachine 47 | { 48 | if (!(awaiter is LogicLooperCoroutineFrameAwaitable)) 49 | throw new InvalidOperationException($"Cannot use general-purpose awaitable in the Coroutine action. Use {nameof(LogicLooperCoroutineActionContext)}'s methods instead of {nameof(Task)}'s."); 50 | 51 | awaiter.OnCompleted(stateMachine.MoveNext); 52 | } 53 | 54 | public void AwaitUnsafeOnCompleted(ref TAwaiter awaiter, ref TStateMachine stateMachine) 55 | where TAwaiter : ICriticalNotifyCompletion 56 | where TStateMachine : IAsyncStateMachine 57 | { 58 | if (!(awaiter is LogicLooperCoroutineFrameAwaitable)) 59 | throw new InvalidOperationException($"Cannot use general-purpose awaitable in the Coroutine action. Use {nameof(LogicLooperCoroutineActionContext)}'s methods instead of {nameof(Task)}'s."); 60 | 61 | awaiter.UnsafeOnCompleted(stateMachine.MoveNext); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/LogicLooper/CompilerServices/LogicLooperCoroutineAsyncValueTaskMethodBuilder`1.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.Runtime.CompilerServices; 3 | 4 | namespace Cysharp.Threading.CompilerServices; 5 | 6 | #if !DEBUG 7 | [EditorBrowsable(EditorBrowsableState.Never)] 8 | #endif 9 | public struct LogicLooperCoroutineAsyncValueTaskMethodBuilder 10 | { 11 | private readonly LogicLooperCoroutine _coroutine; 12 | 13 | public static LogicLooperCoroutineAsyncValueTaskMethodBuilder Create() 14 | { 15 | return new LogicLooperCoroutineAsyncValueTaskMethodBuilder(new LogicLooperCoroutine(LogicLooperCoroutineActionContext.Current!)); 16 | } 17 | 18 | private LogicLooperCoroutineAsyncValueTaskMethodBuilder(LogicLooperCoroutine coroutine) 19 | { 20 | _coroutine = coroutine ?? throw new ArgumentNullException(nameof(coroutine)); 21 | } 22 | 23 | public void Start(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine 24 | { 25 | stateMachine.MoveNext(); 26 | } 27 | 28 | public void SetStateMachine(IAsyncStateMachine stateMachine) 29 | { 30 | } 31 | 32 | public void SetResult(TResult result) 33 | { 34 | _coroutine.SetResult(result); 35 | } 36 | 37 | public void SetException(Exception exception) 38 | { 39 | _coroutine.SetException(exception); 40 | } 41 | 42 | public LogicLooperCoroutine Task => _coroutine; 43 | 44 | public void AwaitOnCompleted(ref TAwaiter awaiter, ref TStateMachine stateMachine) 45 | where TAwaiter : INotifyCompletion 46 | where TStateMachine : IAsyncStateMachine 47 | { 48 | if (!(awaiter is LogicLooperCoroutineFrameAwaitable)) 49 | throw new InvalidOperationException($"Cannot use general-purpose awaitable in the Coroutine action. Use {nameof(LogicLooperCoroutineActionContext)}'s methods instead of {nameof(Task)}'s."); 50 | 51 | awaiter.OnCompleted(stateMachine.MoveNext); 52 | } 53 | 54 | public void AwaitUnsafeOnCompleted(ref TAwaiter awaiter, ref TStateMachine stateMachine) 55 | where TAwaiter : ICriticalNotifyCompletion 56 | where TStateMachine : IAsyncStateMachine 57 | { 58 | if (!(awaiter is LogicLooperCoroutineFrameAwaitable)) 59 | throw new InvalidOperationException($"Cannot use general-purpose awaitable in the Coroutine action. Use {nameof(LogicLooperCoroutineActionContext)}'s methods instead of {nameof(Task)}'s."); 60 | 61 | awaiter.UnsafeOnCompleted(stateMachine.MoveNext); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/LogicLooper/CompilerServices/LogicLooperCoroutineFrameAwaitable.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.Runtime.CompilerServices; 3 | 4 | namespace Cysharp.Threading.CompilerServices; 5 | 6 | #if !DEBUG 7 | [EditorBrowsable(EditorBrowsableState.Never)] 8 | #endif 9 | public struct LogicLooperCoroutineFrameAwaitable : INotifyCompletion, ICriticalNotifyCompletion 10 | { 11 | private readonly int _waitFrames; 12 | private readonly LogicLooperCoroutine _coroutine; 13 | 14 | public LogicLooperCoroutineFrameAwaitable GetAwaiter() => this; 15 | 16 | public bool IsCompleted => false; 17 | public void GetResult() 18 | { } 19 | 20 | public LogicLooperCoroutineFrameAwaitable(LogicLooperCoroutine coroutine, int waitFrames) 21 | { 22 | _coroutine = coroutine ?? throw new ArgumentNullException(nameof(coroutine)); 23 | _waitFrames = (waitFrames > 0) ? waitFrames - 1 : throw new ArgumentOutOfRangeException(nameof(waitFrames)); 24 | } 25 | 26 | public void OnCompleted(Action continuation) 27 | { 28 | _coroutine.SetContinuation(_waitFrames, continuation); 29 | } 30 | 31 | public void UnsafeOnCompleted(Action continuation) 32 | { 33 | _coroutine.SetContinuation(_waitFrames, continuation); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/LogicLooper/ILogicLooper.cs: -------------------------------------------------------------------------------- 1 | namespace Cysharp.Threading; 2 | 3 | /// 4 | /// Provides interface for update loop programming model. 5 | /// 6 | public interface ILogicLooper : IDisposable 7 | { 8 | /// 9 | /// Gets a unique identifier of the looper. 10 | /// 11 | int Id { get; } 12 | 13 | /// 14 | /// Gets an approximate count of running actions. 15 | /// 16 | int ApproximatelyRunningActions { get; } 17 | 18 | /// 19 | /// Gets a duration of the last processed frame. 20 | /// 21 | TimeSpan LastProcessingDuration { get; } 22 | 23 | /// 24 | /// Gets a target frame rate of the looper. 25 | /// 26 | double TargetFrameRate { get; } 27 | 28 | /// 29 | /// Gets a current frame that elapsed since beginning the looper is started. 30 | /// 31 | long CurrentFrame { get; } 32 | 33 | /// 34 | /// Registers a loop-frame action to the looper and returns to wait for completion. 35 | /// 36 | /// The action that is called every frame in the loop. 37 | /// 38 | Task RegisterActionAsync(LogicLooperActionDelegate loopAction); 39 | 40 | /// 41 | /// Registers a loop-frame action to the looper and returns to wait for completion. 42 | /// 43 | /// The action that is called every frame in the loop. 44 | /// The options of the loop action. 45 | /// 46 | Task RegisterActionAsync(LogicLooperActionDelegate loopAction, LooperActionOptions options); 47 | 48 | /// 49 | /// Registers a loop-frame action with state object to the looper and returns to wait for completion. 50 | /// 51 | /// The action that is called every frame in the loop. 52 | /// The object pass to the loop action. 53 | /// 54 | Task RegisterActionAsync(LogicLooperActionWithStateDelegate loopAction, TState state); 55 | 56 | /// 57 | /// Registers a loop-frame action with state object to the looper and returns to wait for completion. 58 | /// 59 | /// The action that is called every frame in the loop. 60 | /// The object pass to the loop action. 61 | /// The options of the loop action. 62 | /// 63 | Task RegisterActionAsync(LogicLooperActionWithStateDelegate loopAction, TState state, LooperActionOptions options); 64 | 65 | /// 66 | /// [Experimental] Registers an async-aware loop-frame action to the looper and returns to wait for completion. 67 | /// An asynchronous action is executed across multiple frames, differ from the synchronous version. 68 | /// 69 | /// The action that is called every frame in the loop. 70 | /// 71 | Task RegisterActionAsync(LogicLooperAsyncActionDelegate loopAction); 72 | 73 | /// 74 | /// [Experimental] Registers an async-aware loop-frame action to the looper and returns to wait for completion. 75 | /// An asynchronous action is executed across multiple frames, differ from the synchronous version. 76 | /// 77 | /// The action that is called every frame in the loop. 78 | /// The options of the loop action. 79 | /// 80 | Task RegisterActionAsync(LogicLooperAsyncActionDelegate loopAction, LooperActionOptions options); 81 | 82 | /// 83 | /// [Experimental] Registers an async-aware loop-frame action with state object to the looper and returns to wait for completion. 84 | /// An asynchronous action is executed across multiple frames, differ from the synchronous version. 85 | /// 86 | /// The action that is called every frame in the loop. 87 | /// The object pass to the loop action. 88 | /// 89 | Task RegisterActionAsync(LogicLooperAsyncActionWithStateDelegate loopAction, TState state); 90 | 91 | /// 92 | /// [Experimental] Registers an async-aware loop-frame action with state object to the looper and returns to wait for completion. 93 | /// An asynchronous action is executed across multiple frames, differ from the synchronous version. 94 | /// 95 | /// The action that is called every frame in the loop. 96 | /// The object pass to the loop action. 97 | /// The options of the loop action. 98 | /// 99 | Task RegisterActionAsync(LogicLooperAsyncActionWithStateDelegate loopAction, TState state, LooperActionOptions options); 100 | 101 | /// 102 | /// Stops the action loop of the looper. 103 | /// 104 | /// 105 | Task ShutdownAsync(TimeSpan shutdownDelay); 106 | } 107 | -------------------------------------------------------------------------------- /src/LogicLooper/ILogicLooperPool.cs: -------------------------------------------------------------------------------- 1 | namespace Cysharp.Threading; 2 | 3 | public interface ILogicLooperPool : IDisposable 4 | { 5 | /// 6 | /// Gets the pooled looper instances. 7 | /// 8 | IReadOnlyList Loopers { get; } 9 | 10 | /// 11 | /// Registers a loop-frame action to a pooled looper and returns to wait for completion. 12 | /// 13 | /// 14 | /// 15 | Task RegisterActionAsync(LogicLooperActionDelegate loopAction); 16 | 17 | /// 18 | /// Registers a loop-frame action to a pooled looper and returns to wait for completion. 19 | /// 20 | /// 21 | /// 22 | /// 23 | Task RegisterActionAsync(LogicLooperActionDelegate loopAction, LooperActionOptions options); 24 | 25 | /// 26 | /// Registers a loop-frame action with state object to a pooled looper and returns to wait for completion. 27 | /// 28 | /// 29 | /// 30 | /// 31 | Task RegisterActionAsync(LogicLooperActionWithStateDelegate loopAction, TState state); 32 | 33 | /// 34 | /// Registers a loop-frame action with state object to a pooled looper and returns to wait for completion. 35 | /// 36 | /// 37 | /// 38 | /// 39 | /// 40 | Task RegisterActionAsync(LogicLooperActionWithStateDelegate loopAction, TState state, LooperActionOptions options); 41 | 42 | /// 43 | /// [Experimental] Registers an async-aware loop-frame action to a pooled looper and returns to wait for completion. 44 | /// An asynchronous action is executed across multiple frames, differ from the synchronous version. 45 | /// 46 | /// 47 | /// 48 | Task RegisterActionAsync(LogicLooperAsyncActionDelegate loopAction); 49 | 50 | /// 51 | /// [Experimental] Registers an async-aware loop-frame action to a pooled looper and returns to wait for completion. 52 | /// An asynchronous action is executed across multiple frames, differ from the synchronous version. 53 | /// 54 | /// 55 | /// 56 | /// 57 | Task RegisterActionAsync(LogicLooperAsyncActionDelegate loopAction, LooperActionOptions options); 58 | 59 | /// 60 | /// [Experimental] Registers an async-aware loop-frame action with state object to a pooled looper and returns to wait for completion. 61 | /// An asynchronous action is executed across multiple frames, differ from the synchronous version. 62 | /// 63 | /// 64 | /// 65 | /// 66 | Task RegisterActionAsync(LogicLooperAsyncActionWithStateDelegate loopAction, TState state); 67 | 68 | /// 69 | /// [Experimental] Registers an async-aware loop-frame action with state object to a pooled looper and returns to wait for completion. 70 | /// An asynchronous action is executed across multiple frames, differ from the synchronous version. 71 | /// 72 | /// 73 | /// 74 | /// 75 | /// 76 | Task RegisterActionAsync(LogicLooperAsyncActionWithStateDelegate loopAction, TState state, LooperActionOptions options); 77 | 78 | /// 79 | /// Stops all action loop of the loopers. 80 | /// 81 | /// 82 | /// 83 | Task ShutdownAsync(TimeSpan shutdownDelay); 84 | 85 | /// 86 | /// Gets a instance from the pool. This is useful when you want to explicitly register multiple actions on the same loop thread. 87 | /// 88 | /// 89 | ILogicLooper GetLooper(); 90 | } 91 | -------------------------------------------------------------------------------- /src/LogicLooper/ILogicLooperPoolBalancer.cs: -------------------------------------------------------------------------------- 1 | namespace Cysharp.Threading; 2 | 3 | public interface ILogicLooperPoolBalancer 4 | { 5 | LogicLooper GetPooledLooper(LogicLooper[] pooledLoopers); 6 | } 7 | -------------------------------------------------------------------------------- /src/LogicLooper/Icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cysharp/LogicLooper/46d3678da7a500b145a3c434fee0db4a18ebbf53/src/LogicLooper/Icon.png -------------------------------------------------------------------------------- /src/LogicLooper/Internal/LogicLooperSynchronizationContext.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.Runtime.CompilerServices; 3 | 4 | namespace Cysharp.Threading.Internal; 5 | 6 | internal class LogicLooperSynchronizationContext : SynchronizationContext, IDisposable 7 | { 8 | private readonly ILogicLooper _logicLooper; 9 | private readonly ConcurrentQueue<(SendOrPostCallback Callback, object? State)> _queue; 10 | private readonly CancellationTokenSource _cancellationTokenSource; 11 | 12 | private int _initialized; 13 | private Task? _loopTask; 14 | 15 | public LogicLooperSynchronizationContext(ILogicLooper logicLooper) 16 | { 17 | _cancellationTokenSource = new CancellationTokenSource(); 18 | _queue = new ConcurrentQueue<(SendOrPostCallback Callback, object? State)>(); 19 | _logicLooper = logicLooper; 20 | 21 | } 22 | 23 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 24 | private void EnsureRunDequeueLoop() 25 | { 26 | if (Interlocked.CompareExchange(ref _initialized, 1, 0) != 0) return; // the dequeue loop has already started. 27 | StartDequeueLoop(); 28 | } 29 | 30 | private void StartDequeueLoop() 31 | { 32 | if (_loopTask != null) 33 | { 34 | throw new InvalidOperationException("The dequeue loop has already started."); 35 | } 36 | 37 | _loopTask = _logicLooper.RegisterActionAsync((in LogicLooperActionContext ctx, CancellationToken cancellationToken) => 38 | { 39 | while (_queue.TryDequeue(out var action)) 40 | { 41 | action.Callback(action.State); 42 | } 43 | 44 | return !cancellationToken.IsCancellationRequested; 45 | }, _cancellationTokenSource.Token); 46 | } 47 | 48 | public override void Post(SendOrPostCallback d, object? state) 49 | { 50 | EnsureRunDequeueLoop(); 51 | 52 | _queue.Enqueue((d, state)); 53 | } 54 | 55 | public override void Send(SendOrPostCallback d, object? state) 56 | { 57 | throw new NotSupportedException(); 58 | } 59 | 60 | public void Dispose() 61 | { 62 | _cancellationTokenSource.Cancel(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/LogicLooper/Internal/MinimumQueue.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | namespace Cysharp.Threading.Internal; 4 | 5 | // optimized version of Standard Queue. 6 | internal class MinimumQueue 7 | { 8 | const int MinimumGrow = 4; 9 | const int GrowFactor = 200; 10 | 11 | T[] array; 12 | int head; 13 | int tail; 14 | int size; 15 | 16 | public MinimumQueue(int capacity) 17 | { 18 | if (capacity < 0) throw new ArgumentOutOfRangeException("capacity"); 19 | array = new T[capacity]; 20 | head = tail = size = 0; 21 | } 22 | 23 | public int Count 24 | { 25 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 26 | get { return size; } 27 | } 28 | 29 | public T Peek() 30 | { 31 | if (size == 0) ThrowForEmptyQueue(); 32 | return array[head]; 33 | } 34 | 35 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 36 | public void Enqueue(T item) 37 | { 38 | if (size == array.Length) 39 | { 40 | Grow(); 41 | } 42 | 43 | array[tail] = item; 44 | MoveNext(ref tail); 45 | size++; 46 | } 47 | 48 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 49 | public T Dequeue() 50 | { 51 | if (size == 0) ThrowForEmptyQueue(); 52 | 53 | int head = this.head; 54 | T[] array = this.array; 55 | T removed = array[head]; 56 | array[head] = default!; 57 | MoveNext(ref this.head); 58 | size--; 59 | return removed; 60 | } 61 | 62 | void Grow() 63 | { 64 | int newcapacity = (int)((long)array.Length * (long)GrowFactor / 100); 65 | if (newcapacity < array.Length + MinimumGrow) 66 | { 67 | newcapacity = array.Length + MinimumGrow; 68 | } 69 | SetCapacity(newcapacity); 70 | } 71 | 72 | void SetCapacity(int capacity) 73 | { 74 | T[] newarray = new T[capacity]; 75 | if (size > 0) 76 | { 77 | if (head < tail) 78 | { 79 | Array.Copy(array, head, newarray, 0, size); 80 | } 81 | else 82 | { 83 | Array.Copy(array, head, newarray, 0, array.Length - head); 84 | Array.Copy(array, 0, newarray, array.Length - head, tail); 85 | } 86 | } 87 | 88 | array = newarray; 89 | head = 0; 90 | tail = (size == capacity) ? 0 : size; 91 | } 92 | 93 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 94 | void MoveNext(ref int index) 95 | { 96 | int tmp = index + 1; 97 | if (tmp == array.Length) 98 | { 99 | tmp = 0; 100 | } 101 | index = tmp; 102 | } 103 | 104 | void ThrowForEmptyQueue() 105 | { 106 | throw new InvalidOperationException("EmptyQueue"); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/LogicLooper/Internal/NotInitializedLogicLooperPool.cs: -------------------------------------------------------------------------------- 1 | namespace Cysharp.Threading.Internal; 2 | 3 | internal class NotInitializedLogicLooperPool : ILogicLooperPool 4 | { 5 | IReadOnlyList ILogicLooperPool.Loopers => throw new NotImplementedException(); 6 | 7 | public Task RegisterActionAsync(LogicLooperActionDelegate loopAction) 8 | => throw new InvalidOperationException("LogicLooper.Shared is not initialized yet."); 9 | 10 | public Task RegisterActionAsync(LogicLooperActionDelegate loopAction, LooperActionOptions options) 11 | => throw new InvalidOperationException("LogicLooper.Shared is not initialized yet."); 12 | 13 | public Task RegisterActionAsync(LogicLooperActionWithStateDelegate loopAction, TState state) 14 | => throw new InvalidOperationException("LogicLooper.Shared is not initialized yet."); 15 | 16 | public Task RegisterActionAsync(LogicLooperActionWithStateDelegate loopAction, TState state, LooperActionOptions options) 17 | => throw new InvalidOperationException("LogicLooper.Shared is not initialized yet."); 18 | 19 | public Task RegisterActionAsync(LogicLooperAsyncActionDelegate loopAction) 20 | => throw new InvalidOperationException("LogicLooper.Shared is not initialized yet."); 21 | 22 | public Task RegisterActionAsync(LogicLooperAsyncActionDelegate loopAction, LooperActionOptions options) 23 | => throw new InvalidOperationException("LogicLooper.Shared is not initialized yet."); 24 | 25 | public Task RegisterActionAsync(LogicLooperAsyncActionWithStateDelegate loopAction, TState state) 26 | => throw new InvalidOperationException("LogicLooper.Shared is not initialized yet."); 27 | 28 | public Task RegisterActionAsync(LogicLooperAsyncActionWithStateDelegate loopAction, TState state, LooperActionOptions options) 29 | => throw new InvalidOperationException("LogicLooper.Shared is not initialized yet."); 30 | 31 | public Task ShutdownAsync(TimeSpan shutdownDelay) 32 | => throw new InvalidOperationException("LogicLooper.Shared is not initialized yet."); 33 | 34 | public ILogicLooper GetLooper() 35 | => throw new InvalidOperationException("LogicLooper.Shared is not initialized yet."); 36 | 37 | public void Dispose() { } 38 | } 39 | -------------------------------------------------------------------------------- /src/LogicLooper/Internal/SleepInterop.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | using Windows.Win32; 5 | 6 | namespace Cysharp.Threading.Internal; 7 | 8 | internal static class SleepInterop 9 | { 10 | private static readonly bool _isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); 11 | 12 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 13 | public static void Sleep(int millisecondsTimeout) 14 | { 15 | #if NET5_0_OR_GREATER 16 | if (OperatingSystem.IsWindows()) 17 | #else 18 | if (_isWindows) 19 | #endif 20 | { 21 | Win32WaitableTimerSleep.Sleep(millisecondsTimeout); 22 | } 23 | else 24 | { 25 | Thread.Sleep(millisecondsTimeout); 26 | } 27 | } 28 | 29 | private static class Win32WaitableTimerSleep 30 | { 31 | private const uint CREATE_WAITABLE_TIMER_MANUAL_RESET = 0x00000001; 32 | private const uint CREATE_WAITABLE_TIMER_HIGH_RESOLUTION = 0x00000002; 33 | 34 | [ThreadStatic] 35 | private static SafeHandle? _timerHandle; 36 | 37 | [MethodImpl(MethodImplOptions.AggressiveInlining)] 38 | public static unsafe void Sleep(int milliseconds) 39 | { 40 | #if NET5_0_OR_GREATER 41 | // https://learn.microsoft.com/en-us/dotnet/standard/analyzers/platform-compat-analyzer#assert-the-call-site-with-platform-check 42 | Debug.Assert(OperatingSystem.IsWindows()); 43 | Debug.Assert(OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17134)); // Windows 10 version 1803 or newer 44 | #endif 45 | _timerHandle ??= PInvoke.CreateWaitableTimerEx(null, default(string?), CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, 0x1F0003 /* TIMER_ALL_ACCESS */); 46 | var result = PInvoke.SetWaitableTimer(_timerHandle, milliseconds * -10000, 0, null, null, false); 47 | var resultWait = PInvoke.WaitForSingleObject(_timerHandle, 0xffffffff /* Infinite */); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/LogicLooper/LogicLooper.cs: -------------------------------------------------------------------------------- 1 | using Cysharp.Threading.Internal; 2 | 3 | // ReSharper disable StringLiteralTypo 4 | // ReSharper disable IdentifierTypo 5 | 6 | namespace Cysharp.Threading; 7 | 8 | public delegate bool LogicLooperActionDelegate(in LogicLooperActionContext ctx); 9 | public delegate bool LogicLooperActionWithStateDelegate(in LogicLooperActionContext ctx, T state); 10 | public delegate ValueTask LogicLooperAsyncActionDelegate(LogicLooperActionContext ctx); 11 | public delegate ValueTask LogicLooperAsyncActionWithStateDelegate(LogicLooperActionContext ctx, T state); 12 | 13 | /// 14 | /// Provides update loop programming model. the looper ties thread and while-loop and call registered methods every frame. 15 | /// 16 | public sealed class LogicLooper : ILogicLooper, IDisposable 17 | { 18 | private static int _looperSequence = 0; 19 | 20 | [ThreadStatic] 21 | private static ILogicLooper? _threadLocalLooper = default; 22 | 23 | /// 24 | /// Gets a looper of the current thread. 25 | /// 26 | public static ILogicLooper? Current 27 | { 28 | get => _threadLocalLooper; 29 | 30 | // NOTE: Setter to set from ManualLogicLooper for testing purposes 31 | internal set => _threadLocalLooper = value; 32 | } 33 | 34 | private readonly int _looperId; 35 | private readonly Thread _runLoopThread; 36 | private readonly CancellationTokenSource _ctsLoop; 37 | private readonly CancellationTokenSource _ctsAction; 38 | private readonly TaskCompletionSource _shutdownTaskAwaiter; 39 | private readonly double _targetFrameRate; 40 | private readonly int _targetFrameTimeMilliseconds; 41 | private readonly MinimumQueue _registerQueue; 42 | private readonly object _lockActions = new object(); 43 | private readonly object _lockQueue = new object(); 44 | private readonly int _growFactor = 2; 45 | private readonly TimeProvider _timeProvider; 46 | 47 | private int _tail = 0; 48 | private bool _isRunning = false; 49 | private LooperAction[] _actions; 50 | private long _lastProcessingDuration = 0; 51 | private int _isShuttingDown = 0; 52 | private long _frame = 0; 53 | 54 | /// 55 | public int Id => _looperId; 56 | 57 | /// 58 | public int ApproximatelyRunningActions => _tail; 59 | 60 | /// 61 | public TimeSpan LastProcessingDuration => TimeSpan.FromMilliseconds(_lastProcessingDuration); 62 | 63 | /// 64 | public double TargetFrameRate => _targetFrameRate; 65 | 66 | /// 67 | /// Gets a unique identifier of the managed thread. 68 | /// 69 | internal int ThreadId => _runLoopThread.ManagedThreadId; 70 | 71 | /// 72 | public long CurrentFrame => _frame; 73 | 74 | public LogicLooper(int targetFrameRate, int initialActionsCapacity = 16) 75 | : this(TimeSpan.FromMilliseconds(1000 / (double)targetFrameRate), initialActionsCapacity) 76 | { 77 | } 78 | 79 | public LogicLooper(TimeSpan targetFrameTime, int initialActionsCapacity = 16) 80 | { 81 | _targetFrameRate = 1000 / targetFrameTime.TotalMilliseconds; 82 | _looperId = Interlocked.Increment(ref _looperSequence); 83 | _ctsLoop = new CancellationTokenSource(); 84 | _ctsAction = new CancellationTokenSource(); 85 | _targetFrameTimeMilliseconds = (int)targetFrameTime.TotalMilliseconds; 86 | _registerQueue = new MinimumQueue(10); 87 | _runLoopThread = new Thread(StartRunLoop) 88 | { 89 | Name = $"{typeof(LogicLooper).Name}-{_looperId}", 90 | IsBackground = true, 91 | Priority = ThreadPriority.AboveNormal, 92 | }; 93 | _shutdownTaskAwaiter = new TaskCompletionSource(); 94 | _actions = new LooperAction[initialActionsCapacity]; 95 | _timeProvider = TimeProvider.System; 96 | 97 | _runLoopThread.Start(this); 98 | } 99 | 100 | /// 101 | /// Registers a loop-frame action to the looper and returns to wait for completion. 102 | /// 103 | /// 104 | /// 105 | public Task RegisterActionAsync(LogicLooperActionDelegate loopAction) 106 | => RegisterActionAsync(loopAction, LooperActionOptions.Default); 107 | 108 | /// 109 | /// Registers a loop-frame action to the looper and returns to wait for completion. 110 | /// 111 | /// 112 | /// 113 | /// 114 | public Task RegisterActionAsync(LogicLooperActionDelegate loopAction, LooperActionOptions options) 115 | { 116 | var action = new LooperAction(DelegateHelper.GetWrapper(), loopAction, null, options, _timeProvider); 117 | return RegisterActionAsyncCore(action); 118 | } 119 | 120 | /// 121 | /// Registers a loop-frame action with state object to the looper and returns to wait for completion. 122 | /// 123 | /// 124 | /// 125 | /// 126 | public Task RegisterActionAsync(LogicLooperActionWithStateDelegate loopAction, TState state) 127 | => RegisterActionAsync(loopAction, state, LooperActionOptions.Default); 128 | 129 | /// 130 | /// Registers a loop-frame action with state object to the looper and returns to wait for completion. 131 | /// 132 | /// 133 | /// 134 | /// 135 | /// 136 | public Task RegisterActionAsync(LogicLooperActionWithStateDelegate loopAction, TState state, LooperActionOptions options) 137 | { 138 | var action = new LooperAction(DelegateHelper.GetWrapper(), loopAction, state, options, _timeProvider); 139 | return RegisterActionAsyncCore(action); 140 | } 141 | 142 | /// 143 | /// [Experimental] Registers a async-aware loop-frame action to the looper and returns to wait for completion. 144 | /// 145 | /// 146 | /// 147 | public Task RegisterActionAsync(LogicLooperAsyncActionDelegate loopAction) 148 | => RegisterActionAsync(loopAction, LooperActionOptions.Default); 149 | 150 | /// 151 | /// [Experimental] Registers a async-aware loop-frame action to the looper and returns to wait for completion. 152 | /// 153 | /// 154 | /// 155 | /// 156 | public async Task RegisterActionAsync(LogicLooperAsyncActionDelegate loopAction, LooperActionOptions options) 157 | { 158 | var action = new LooperAction(DelegateHelper.GetWrapper(), DelegateHelper.ConvertAsyncToSync(loopAction), null, options, _timeProvider); 159 | await RegisterActionAsyncCore(action); 160 | } 161 | 162 | /// 163 | /// [Experimental] Registers a async-aware loop-frame action with state object to the looper and returns to wait for completion. 164 | /// 165 | /// 166 | /// 167 | /// 168 | public Task RegisterActionAsync(LogicLooperAsyncActionWithStateDelegate loopAction, TState state) 169 | => RegisterActionAsync(loopAction, state, LooperActionOptions.Default); 170 | 171 | /// 172 | /// [Experimental] Registers a async-aware loop-frame action with state object to the looper and returns to wait for completion. 173 | /// 174 | /// 175 | /// 176 | /// 177 | /// 178 | public async Task RegisterActionAsync(LogicLooperAsyncActionWithStateDelegate loopAction, TState state, LooperActionOptions options) 179 | { 180 | var action = new LooperAction(DelegateHelper.GetWrapper(), DelegateHelper.ConvertAsyncToSync(loopAction), state, options, _timeProvider); 181 | await RegisterActionAsyncCore(action); 182 | } 183 | 184 | /// 185 | /// Stops the action loop of the looper. 186 | /// 187 | /// 188 | public async Task ShutdownAsync(TimeSpan shutdownDelay) 189 | { 190 | if (Interlocked.CompareExchange(ref _isShuttingDown, 1, 0) == 0) 191 | { 192 | _ctsAction.Cancel(); 193 | 194 | if (shutdownDelay == TimeSpan.Zero) 195 | { 196 | _ctsLoop.Cancel(); 197 | } 198 | else 199 | { 200 | _ctsLoop.CancelAfter(shutdownDelay); 201 | } 202 | } 203 | 204 | await _shutdownTaskAwaiter.Task.ConfigureAwait(false); 205 | } 206 | 207 | public void Dispose() 208 | { 209 | ShutdownAsync(TimeSpan.Zero).GetAwaiter().GetResult(); 210 | } 211 | 212 | private Task RegisterActionAsyncCore(LooperAction action) 213 | { 214 | if (action.Options.TargetFrameRateOverride is { } targetFrameRateOverride && targetFrameRateOverride > this.TargetFrameRate) 215 | { 216 | throw new InvalidOperationException("Option 'TargetFrameRateOverride' must be less than Looper's target frame rate."); 217 | } 218 | 219 | lock (_lockQueue) 220 | { 221 | if (_isRunning) 222 | { 223 | _registerQueue.Enqueue(action); 224 | return action.Future.Task; 225 | } 226 | } 227 | 228 | lock (_lockActions) 229 | { 230 | if (_actions.Length == _tail) 231 | { 232 | Array.Resize(ref _actions, checked(_tail * _growFactor)); 233 | } 234 | _actions[_tail++] = action; 235 | } 236 | 237 | return action.Future.Task; 238 | } 239 | 240 | private static void StartRunLoop(object? state) 241 | { 242 | var logicLooper = ((LogicLooper)state!); 243 | _threadLocalLooper = logicLooper; 244 | logicLooper.RunLoop(); 245 | } 246 | 247 | private void RunLoop() 248 | { 249 | var lastTimestamp = _timeProvider.GetTimestamp(); 250 | 251 | var syncContext = new LogicLooperSynchronizationContext(this); 252 | SynchronizationContext.SetSynchronizationContext(syncContext); 253 | 254 | while (!_ctsLoop.IsCancellationRequested) 255 | { 256 | var begin = _timeProvider.GetTimestamp(); 257 | 258 | lock (_lockQueue) 259 | { 260 | _isRunning = true; 261 | } 262 | 263 | lock (_lockActions) 264 | { 265 | var elapsedTimeFromPreviousFrame = _timeProvider.GetElapsedTime(lastTimestamp, begin); 266 | lastTimestamp = begin; 267 | 268 | var ctx = new LogicLooperActionContext(this, _frame++, begin, elapsedTimeFromPreviousFrame, _ctsAction.Token); 269 | 270 | var j = _tail - 1; 271 | for (var i = 0; i < _actions.Length; i++) 272 | { 273 | ref var action = ref _actions[i]; 274 | 275 | // Found an action and invoke it. 276 | if (action.Action != null) 277 | { 278 | if (!InvokeAction(ctx, ref action, begin, _ctsAction.Token)) 279 | { 280 | action = default; 281 | } 282 | continue; 283 | } 284 | 285 | // Invoke actions from tail. 286 | while (i < j) 287 | { 288 | ref var actionFromTail = ref _actions[j]; 289 | 290 | j--; // consumed 291 | 292 | if (actionFromTail.Action != null) 293 | { 294 | if (!InvokeAction(ctx, ref actionFromTail, begin, _ctsAction.Token)) 295 | { 296 | actionFromTail = default; 297 | continue; // Continue the reverse loop flow. 298 | } 299 | else 300 | { 301 | action = actionFromTail; // Swap the null element and the action. 302 | actionFromTail = default; 303 | goto NextActionLoop; // Resume to the regular flow. 304 | } 305 | } 306 | } 307 | 308 | _tail = i; 309 | break; 310 | 311 | NextActionLoop: 312 | continue; 313 | } 314 | 315 | lock (_lockQueue) 316 | { 317 | _isRunning = false; 318 | 319 | while (_registerQueue.Count != 0) 320 | { 321 | if (_actions.Length == _tail) 322 | { 323 | Array.Resize(ref _actions, checked(_tail * _growFactor)); 324 | } 325 | _actions[_tail++] = _registerQueue.Dequeue(); 326 | } 327 | } 328 | } 329 | 330 | var now = _timeProvider.GetTimestamp(); 331 | var elapsedMilliseconds = (int)(_timeProvider.GetElapsedTime(begin, now).TotalMilliseconds); 332 | _lastProcessingDuration = elapsedMilliseconds; 333 | 334 | var waitForNextFrameMilliseconds = (int)(_targetFrameTimeMilliseconds - elapsedMilliseconds); 335 | if (waitForNextFrameMilliseconds > 0) 336 | { 337 | SleepInterop.Sleep(waitForNextFrameMilliseconds); 338 | } 339 | } 340 | 341 | _shutdownTaskAwaiter.SetResult(true); 342 | } 343 | 344 | private bool InvokeAction(in LogicLooperActionContext ctx, ref LooperAction action, long begin, CancellationToken actionStoppingToken) 345 | { 346 | var hasNext = true; 347 | if (action.LoopActionOverrideState.IsOverridden) 348 | { 349 | // If the action with fps override haven't reached the next invoke timestamp, we don't need to perform the action. 350 | if (action.LoopActionOverrideState.NextScheduledTimestamp > ctx.FrameBeginTimestamp) 351 | { 352 | return true; 353 | } 354 | 355 | var exceededTimestamp = action.LoopActionOverrideState.NextScheduledTimestamp == 0 ? 0 : ctx.FrameBeginTimestamp - action.LoopActionOverrideState.NextScheduledTimestamp; 356 | var elapsedTimeFromPreviousFrameOverride = _timeProvider.GetElapsedTime(begin, action.LoopActionOverrideState.LastInvokedAt); 357 | var overrideCtx = new LogicLooperActionContext(this, action.LoopActionOverrideState.Frame++, begin, elapsedTimeFromPreviousFrameOverride, actionStoppingToken); 358 | 359 | hasNext = InvokeActionCore(overrideCtx, ref action); 360 | 361 | action.LoopActionOverrideState.LastInvokedAt = begin; 362 | action.LoopActionOverrideState.NextScheduledTimestamp = begin + action.LoopActionOverrideState.TargetFrameTimeTimestamp - exceededTimestamp; 363 | } 364 | else 365 | { 366 | hasNext = InvokeActionCore(ctx, ref action); 367 | } 368 | 369 | return hasNext; 370 | } 371 | 372 | private static bool InvokeActionCore(in LogicLooperActionContext ctx, ref LooperAction action) 373 | { 374 | try 375 | { 376 | var hasNext = action.Invoke(ctx); 377 | 378 | if (!hasNext) 379 | { 380 | action.Future.SetResult(true); 381 | } 382 | 383 | return hasNext; 384 | } 385 | catch (Exception ex) 386 | { 387 | action.Future.SetException(ex); 388 | } 389 | 390 | return false; 391 | } 392 | 393 | internal class DelegateHelper 394 | { 395 | private static InternalLogicLooperActionDelegate _wrapper => (object wrappedDelegate, in LogicLooperActionContext ctx, object? state) => ((LogicLooperActionDelegate)wrappedDelegate)(ctx); 396 | 397 | public static InternalLogicLooperActionDelegate GetWrapper() => Cache.Wrapper; 398 | public static InternalLogicLooperActionDelegate GetWrapper() => _wrapper; 399 | 400 | public static LogicLooperActionDelegate ConvertAsyncToSync(LogicLooperAsyncActionDelegate loopAction) 401 | { 402 | // TODO: perf 403 | var runningTask = default(ValueTask?); 404 | return LoopActionSync; 405 | 406 | bool LoopActionSync(in LogicLooperActionContext ctx) 407 | { 408 | runningTask ??= loopAction(ctx); 409 | 410 | if (runningTask is { IsCompleted: true } completedTask) 411 | { 412 | var result = completedTask.GetAwaiter().GetResult(); 413 | runningTask = null; 414 | return result; 415 | } 416 | 417 | return true; 418 | } 419 | } 420 | 421 | public static LogicLooperActionWithStateDelegate ConvertAsyncToSync(LogicLooperAsyncActionWithStateDelegate loopAction) 422 | { 423 | // TODO: perf 424 | var runningTask = default(ValueTask?); 425 | return LoopActionSync; 426 | 427 | bool LoopActionSync(in LogicLooperActionContext ctx, TState state) 428 | { 429 | runningTask ??= loopAction(ctx, state); 430 | 431 | if (runningTask is { IsCompleted: true } completedTask) 432 | { 433 | var result = completedTask.GetAwaiter().GetResult(); 434 | runningTask = null; 435 | return result; 436 | } 437 | 438 | return true; 439 | } 440 | } 441 | 442 | static class Cache 443 | { 444 | public static InternalLogicLooperActionDelegate Wrapper => (object wrappedDelegate, in LogicLooperActionContext ctx, object? state) => ((LogicLooperActionWithStateDelegate)wrappedDelegate)(ctx, (T)state!); 445 | } 446 | } 447 | 448 | internal delegate bool InternalLogicLooperActionDelegate(object wrappedDelegate, in LogicLooperActionContext ctx, object? state); 449 | 450 | internal struct LooperAction 451 | { 452 | public DateTimeOffset BeginAt { get; } 453 | public object? State { get; } 454 | public Delegate? Action { get; } 455 | public InternalLogicLooperActionDelegate ActionWrapper { get; } 456 | public TaskCompletionSource Future { get; } 457 | public LooperActionOptions Options { get; } 458 | 459 | public LoopActionOverrideState LoopActionOverrideState; 460 | 461 | public LooperAction(InternalLogicLooperActionDelegate actionWrapper, Delegate action, object? state, LooperActionOptions options, TimeProvider timeProvider) 462 | { 463 | BeginAt = DateTimeOffset.Now; 464 | ActionWrapper = actionWrapper ?? throw new ArgumentNullException(nameof(actionWrapper)); 465 | Action = action ?? throw new ArgumentNullException(nameof(action)); 466 | State = state; 467 | Future = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); 468 | Options = options; 469 | LoopActionOverrideState = default; 470 | 471 | if (options.TargetFrameRateOverride is { } targetFrameRateOverride) 472 | { 473 | var timestampsToTicks = TimeSpan.TicksPerSecond / (double)timeProvider.TimestampFrequency; 474 | LoopActionOverrideState = new LoopActionOverrideState(isOverridden: true) 475 | { 476 | TargetFrameTimeTimestamp = (long)(TimeSpan.FromMilliseconds(1000 / (double)targetFrameRateOverride).Ticks / timestampsToTicks), 477 | }; 478 | } 479 | } 480 | 481 | public bool Invoke(in LogicLooperActionContext ctx) 482 | { 483 | return ActionWrapper(Action!, ctx, State); 484 | } 485 | } 486 | 487 | internal struct LoopActionOverrideState 488 | { 489 | public bool IsOverridden { get; } 490 | 491 | public int Frame { get; set; } 492 | public long NextScheduledTimestamp { get; set; } 493 | public long LastInvokedAt { get; set; } 494 | public long TargetFrameTimeTimestamp { get; set; } 495 | 496 | public LoopActionOverrideState(bool isOverridden) 497 | { 498 | IsOverridden = isOverridden; 499 | } 500 | } 501 | } 502 | -------------------------------------------------------------------------------- /src/LogicLooper/LogicLooper.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netstandard2.0;netstandard2.1;net8.0;net9.0 5 | latest 6 | enable 7 | enable 8 | Cysharp.Threading 9 | Cysharp.Threading.LogicLooper 10 | true 11 | opensource.snk 12 | true 13 | $(NoWarn);1591 14 | true 15 | 16 | 17 | LogicLooper 18 | true 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | all 37 | runtime; build; native; contentfiles; analyzers; buildtransitive 38 | 39 | 40 | all 41 | runtime; build; native; contentfiles; analyzers; buildtransitive 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/LogicLooper/LogicLooperActionContext.cs: -------------------------------------------------------------------------------- 1 | namespace Cysharp.Threading; 2 | 3 | /// 4 | /// Represents the current loop-action contextual values. 5 | /// 6 | public readonly struct LogicLooperActionContext 7 | { 8 | /// 9 | /// Gets a looper for the current action. 10 | /// 11 | public ILogicLooper Looper { get; } 12 | 13 | /// 14 | /// Gets a current frame that elapsed since beginning the looper is started. 15 | /// 16 | public long CurrentFrame { get; } 17 | 18 | /// 19 | /// Gets a timestamp for begin of the current frame. 20 | /// 21 | public long FrameBeginTimestamp { get; } 22 | 23 | /// 24 | /// Gets an elapsed time since the previous frame has proceeded. This is the equivalent to Time.deltaTime on Unity. 25 | /// 26 | public TimeSpan ElapsedTimeFromPreviousFrame { get; } 27 | 28 | /// 29 | /// Gets the cancellation token for the loop. 30 | /// 31 | public CancellationToken CancellationToken { get; } 32 | 33 | public LogicLooperActionContext(ILogicLooper looper, long currentFrame, long frameBeginTimestamp, TimeSpan elapsedTimeFromPreviousFrame, CancellationToken cancellationToken) 34 | { 35 | Looper = looper ?? throw new ArgumentNullException(nameof(looper)); 36 | CurrentFrame = currentFrame; 37 | FrameBeginTimestamp = frameBeginTimestamp; 38 | ElapsedTimeFromPreviousFrame = elapsedTimeFromPreviousFrame; 39 | CancellationToken = cancellationToken; 40 | } 41 | 42 | /// 43 | /// Launch the specified action as a new coroutine-like operation in the current looper. 44 | /// 45 | /// 46 | /// 47 | public LogicLooperCoroutine RunCoroutine(Func action) 48 | { 49 | LogicLooperCoroutineActionContext.SetCurrent(new LogicLooperCoroutineActionContext(this)); 50 | var coroutineTask = action(LogicLooperCoroutineActionContext.Current!); 51 | 52 | if (coroutineTask.IsCompleted) 53 | { 54 | return coroutineTask; 55 | } 56 | 57 | this.Looper.RegisterActionAsync((in LogicLooperActionContext ctx2, LogicLooperCoroutine state) => state.Update(ctx2), coroutineTask); 58 | return coroutineTask; 59 | } 60 | 61 | /// 62 | /// Launch the specified action as a new coroutine-like operation in the current looper. 63 | /// 64 | /// 65 | /// 66 | public LogicLooperCoroutine RunCoroutine(Func> action) 67 | { 68 | LogicLooperCoroutineActionContext.SetCurrent(new LogicLooperCoroutineActionContext(this)); 69 | var coroutineTask = action(LogicLooperCoroutineActionContext.Current!); 70 | 71 | if (coroutineTask.IsCompleted) 72 | { 73 | return coroutineTask; 74 | } 75 | 76 | this.Looper.RegisterActionAsync((in LogicLooperActionContext ctx2, LogicLooperCoroutine state) => state.Update(ctx2), coroutineTask); 77 | return coroutineTask; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/LogicLooper/LogicLooperCoroutine.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | using Cysharp.Threading.CompilerServices; 3 | 4 | namespace Cysharp.Threading; 5 | 6 | /// 7 | /// Represents a coroutine-like operation. 8 | /// 9 | [AsyncMethodBuilder(typeof(LogicLooperCoroutineAsyncValueTaskMethodBuilder))] 10 | public class LogicLooperCoroutine 11 | { 12 | private readonly LogicLooperCoroutineActionContext _context; 13 | 14 | private (int WaitFrames, Action? Action) _next; 15 | private LogicLooperCoroutineStatus _status = LogicLooperCoroutineStatus.Created; 16 | 17 | /// 18 | /// Gets whether the coroutine-like operation has completed. 19 | /// 20 | public bool IsCompleted 21 | => _status == LogicLooperCoroutineStatus.RanToCompletion || 22 | _status == LogicLooperCoroutineStatus.Faulted; 23 | 24 | /// 25 | /// Gets whether the coroutine-like operation has completed successfully. 26 | /// 27 | public bool IsCompletedSuccessfully 28 | => _status == LogicLooperCoroutineStatus.RanToCompletion; 29 | 30 | /// 31 | /// Gets whether the coroutine-like operation completed due to unhandled exception. 32 | /// 33 | public bool IsFaulted 34 | => _status == LogicLooperCoroutineStatus.Faulted; 35 | 36 | /// 37 | /// Gets an that thrown while running in the update. 38 | /// 39 | public Exception? Exception { get; private set; } 40 | 41 | internal LogicLooperCoroutine(LogicLooperCoroutineActionContext context) 42 | { 43 | _context = context ?? throw new ArgumentNullException(nameof(context)); 44 | _context.SetCoroutine(this); 45 | } 46 | 47 | internal void SetException(Exception exception) 48 | { 49 | if (IsCompleted) throw new InvalidOperationException("The coroutine has already been completed."); 50 | 51 | Exception = exception ?? throw new ArgumentNullException(nameof(exception)); 52 | _status = LogicLooperCoroutineStatus.Faulted; 53 | } 54 | 55 | internal void SetResult() 56 | { 57 | if (IsCompleted) throw new InvalidOperationException("The coroutine has already been completed."); 58 | 59 | _status = LogicLooperCoroutineStatus.RanToCompletion; 60 | } 61 | 62 | internal void SetContinuation(int waitFrames, Action continuation) 63 | { 64 | _next = (waitFrames, continuation); 65 | } 66 | 67 | internal bool Update(in LogicLooperActionContext ctx) 68 | { 69 | _status = LogicLooperCoroutineStatus.Running; 70 | _context.SetActionContext(ctx); 71 | 72 | { 73 | var next = _next; 74 | _next = (0, null); 75 | 76 | if (next.WaitFrames == 0) 77 | { 78 | next.Action!(); 79 | } 80 | else 81 | { 82 | _next = (next.WaitFrames - 1, next.Action); 83 | } 84 | 85 | if (Exception != null) 86 | { 87 | throw Exception; 88 | } 89 | } 90 | 91 | return _next.Action != null; 92 | } 93 | } 94 | 95 | /// 96 | /// Represents a coroutine-like operation. 97 | /// 98 | [AsyncMethodBuilder(typeof(LogicLooperCoroutineAsyncValueTaskMethodBuilder<>))] 99 | public sealed class LogicLooperCoroutine : LogicLooperCoroutine 100 | { 101 | private TResult? _result; 102 | 103 | public TResult? Result 104 | { 105 | get 106 | { 107 | if (IsFaulted && Exception != null) throw Exception; 108 | if (!IsCompleted) throw new InvalidOperationException("The coroutine is not completed yet."); 109 | 110 | return _result; 111 | } 112 | } 113 | 114 | internal void SetResult(TResult result) 115 | { 116 | if (IsCompleted) throw new InvalidOperationException("The coroutine has already been completed."); 117 | 118 | _result = result; 119 | 120 | base.SetResult(); // The coroutine should be RanToCompletion status. 121 | } 122 | 123 | internal LogicLooperCoroutine(LogicLooperCoroutineActionContext context) 124 | : base(context) 125 | { 126 | _result = default; 127 | } 128 | } 129 | 130 | internal enum LogicLooperCoroutineStatus 131 | { 132 | Created, 133 | Running, 134 | RanToCompletion, 135 | Faulted, 136 | } 137 | -------------------------------------------------------------------------------- /src/LogicLooper/LogicLooperCoroutineActionContext.cs: -------------------------------------------------------------------------------- 1 | using Cysharp.Threading.CompilerServices; 2 | 3 | namespace Cysharp.Threading; 4 | 5 | /// 6 | /// Represents the current coroutine-like contextual values. 7 | /// 8 | public sealed class LogicLooperCoroutineActionContext 9 | { 10 | [ThreadStatic] 11 | private static LogicLooperCoroutineActionContext? _current; 12 | 13 | internal static LogicLooperCoroutineActionContext? Current => _current; 14 | 15 | // NOTE: the field will be set in LogicLooperCoroutine.Update. 16 | private LogicLooperActionContext _actionContext; 17 | 18 | // NOTE: the field will be set in LogicLooperCoroutine..ctor. 19 | private LogicLooperCoroutine _coroutine = default!; 20 | 21 | /// 22 | /// Gets a looper for the current action. 23 | /// 24 | public ILogicLooper Looper => _actionContext.Looper; 25 | 26 | /// 27 | /// Gets a current frame that elapsed since beginning the looper is started. 28 | /// 29 | public long CurrentFrame => _actionContext.CurrentFrame; 30 | 31 | /// 32 | /// Gets an elapsed time since the previous frame has proceeded. 33 | /// 34 | public TimeSpan ElapsedTimeFromPreviousFrame => _actionContext.ElapsedTimeFromPreviousFrame; 35 | 36 | /// 37 | /// Gets the cancellation token for the loop. 38 | /// 39 | public CancellationToken CancellationToken => _actionContext.CancellationToken; 40 | 41 | internal LogicLooperCoroutineActionContext(LogicLooperActionContext ctx) 42 | { 43 | _actionContext = ctx; 44 | } 45 | 46 | internal static void SetCurrent(LogicLooperCoroutineActionContext? context) 47 | { 48 | _current = context; 49 | } 50 | 51 | internal void SetCoroutine(LogicLooperCoroutine coroutine) 52 | { 53 | _coroutine = coroutine ?? throw new ArgumentNullException(nameof(coroutine)); 54 | } 55 | 56 | internal void SetActionContext(in LogicLooperActionContext actionContext) 57 | { 58 | _actionContext = actionContext; 59 | } 60 | 61 | /// 62 | /// Creates an awaitable that resumes the coroutine until next update. 63 | /// 64 | /// 65 | public LogicLooperCoroutineFrameAwaitable DelayNextFrame() 66 | { 67 | return new LogicLooperCoroutineFrameAwaitable(_coroutine, 1); 68 | } 69 | 70 | /// 71 | /// Creates an awaitable that resumes the coroutine after a specified frames. 72 | /// 73 | /// 74 | public LogicLooperCoroutineFrameAwaitable DelayFrame(int waitFrames) 75 | { 76 | return new LogicLooperCoroutineFrameAwaitable(_coroutine, waitFrames); 77 | } 78 | 79 | /// 80 | /// Creates an awaitable that resumes the coroutine after a specified duration. 81 | /// 82 | /// 83 | public LogicLooperCoroutineFrameAwaitable Delay(TimeSpan delay) 84 | { 85 | var frames = this.Looper.TargetFrameRate * delay.TotalSeconds; 86 | return new LogicLooperCoroutineFrameAwaitable(_coroutine, (int)frames); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/LogicLooper/LogicLooperPool.Shared.cs: -------------------------------------------------------------------------------- 1 | using Cysharp.Threading.Internal; 2 | 3 | namespace Cysharp.Threading; 4 | 5 | public sealed partial class LogicLooperPool 6 | { 7 | /// 8 | /// Gets the shared pool of loopers. Requires to call method before use. 9 | /// 10 | public static ILogicLooperPool Shared { get; private set; } = new NotInitializedLogicLooperPool(); 11 | 12 | /// 13 | /// Initializes the shared pool of loopers with specified options. 14 | /// 15 | /// 16 | /// 17 | /// 18 | public static void InitializeSharedPool(int targetFrameRate, int looperCount = 0, ILogicLooperPoolBalancer? balancer = null) 19 | { 20 | if (looperCount == 0) 21 | { 22 | looperCount = Math.Max(1, Environment.ProcessorCount - 1); 23 | } 24 | 25 | Shared = new LogicLooperPool(targetFrameRate, looperCount, balancer ?? RoundRobinLogicLooperPoolBalancer.Instance); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/LogicLooper/LogicLooperPool.cs: -------------------------------------------------------------------------------- 1 | using Cysharp.Threading.Internal; 2 | 3 | namespace Cysharp.Threading; 4 | 5 | /// 6 | /// Provides a pool of loopers that can be register loop-action into the pooled looper. 7 | /// 8 | public sealed partial class LogicLooperPool : ILogicLooperPool, IDisposable 9 | { 10 | private readonly LogicLooper[] _loopers; 11 | private readonly ILogicLooperPoolBalancer _balancer; 12 | private readonly CancellationTokenSource _shutdownTokenSource = new(); 13 | 14 | /// 15 | public IReadOnlyList Loopers => _loopers; 16 | 17 | /// 18 | /// Initialize the looper pool with specified configurations. 19 | /// 20 | /// 21 | /// 22 | /// 23 | public LogicLooperPool(int targetFrameRate, int looperCount, ILogicLooperPoolBalancer balancer) 24 | : this(TimeSpan.FromMilliseconds(1000 / (double)targetFrameRate), looperCount, balancer) 25 | { } 26 | 27 | /// 28 | /// Initialize the looper pool with specified configurations. 29 | /// 30 | /// 31 | /// 32 | /// 33 | public LogicLooperPool(TimeSpan targetFrameTime, int looperCount, ILogicLooperPoolBalancer balancer) 34 | { 35 | if (looperCount <= 0) throw new ArgumentOutOfRangeException(nameof(looperCount), "LooperCount must be more than zero."); 36 | 37 | _loopers = new LogicLooper[looperCount]; 38 | for (var i = 0; i < looperCount; i++) 39 | { 40 | _loopers[i] = new LogicLooper(targetFrameTime); 41 | } 42 | _balancer = balancer ?? throw new ArgumentNullException(nameof(balancer)); 43 | } 44 | 45 | /// 46 | public Task RegisterActionAsync(LogicLooperActionDelegate loopAction) 47 | => GetLooper().RegisterActionAsync(loopAction); 48 | 49 | /// 50 | public Task RegisterActionAsync(LogicLooperActionDelegate loopAction, LooperActionOptions options) 51 | => GetLooper().RegisterActionAsync(loopAction, options); 52 | 53 | /// 54 | public Task RegisterActionAsync(LogicLooperActionWithStateDelegate loopAction, TState state) 55 | => GetLooper().RegisterActionAsync(loopAction, state); 56 | 57 | /// 58 | public Task RegisterActionAsync(LogicLooperActionWithStateDelegate loopAction, TState state, LooperActionOptions options) 59 | => GetLooper().RegisterActionAsync(loopAction, state, options); 60 | 61 | /// 62 | public Task RegisterActionAsync(LogicLooperAsyncActionDelegate loopAction) 63 | => GetLooper().RegisterActionAsync(loopAction); 64 | 65 | /// 66 | public Task RegisterActionAsync(LogicLooperAsyncActionDelegate loopAction, LooperActionOptions options) 67 | => GetLooper().RegisterActionAsync(loopAction, options); 68 | 69 | /// 70 | public Task RegisterActionAsync(LogicLooperAsyncActionWithStateDelegate loopAction, TState state) 71 | => GetLooper().RegisterActionAsync(loopAction, state); 72 | 73 | /// 74 | public Task RegisterActionAsync(LogicLooperAsyncActionWithStateDelegate loopAction, TState state, LooperActionOptions options) 75 | => GetLooper().RegisterActionAsync(loopAction, state, options); 76 | 77 | /// 78 | public async Task ShutdownAsync(TimeSpan shutdownDelay) 79 | { 80 | await Task.WhenAll(_loopers.Select(x => x.ShutdownAsync(shutdownDelay))); 81 | } 82 | 83 | /// 84 | public ILogicLooper GetLooper() 85 | => _balancer.GetPooledLooper(_loopers); 86 | 87 | public void Dispose() 88 | { 89 | foreach (var looper in _loopers) 90 | { 91 | try 92 | { 93 | looper.Dispose(); 94 | } 95 | catch 96 | { 97 | } 98 | } 99 | _shutdownTokenSource.Cancel(); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/LogicLooper/LooperActionOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Cysharp.Threading; 2 | 3 | /// 4 | /// Provides options for the loop-action. 5 | /// 6 | /// Set a override value for the target frame rate. LogicLooper tries to get as close to the target value as possible, but it is not as accurate as the Looper's frame rate. 7 | public record LooperActionOptions(int? TargetFrameRateOverride = null) 8 | { 9 | public static LooperActionOptions Default { get; } = new LooperActionOptions(); 10 | } 11 | -------------------------------------------------------------------------------- /src/LogicLooper/ManualLogicLooper.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | namespace Cysharp.Threading; 4 | 5 | /// 6 | /// Implements to loop update frame manually. 7 | /// 8 | public sealed class ManualLogicLooper : ILogicLooper 9 | { 10 | private readonly List _actions = new List(); 11 | private readonly CancellationTokenSource _ctsAction = new CancellationTokenSource(); 12 | private int _frame; 13 | 14 | /// 15 | public int Id => 0; 16 | 17 | /// 18 | public int ApproximatelyRunningActions => _actions.Count; 19 | 20 | /// 21 | public TimeSpan LastProcessingDuration => TimeSpan.Zero; 22 | 23 | /// 24 | public double TargetFrameRate { get; } 25 | 26 | /// 27 | public long CurrentFrame => _frame; 28 | 29 | public ManualLogicLooper(double targetFrameRate) 30 | { 31 | if (targetFrameRate == 0) throw new ArgumentOutOfRangeException(nameof(targetFrameRate), "TargetFrameRate must be greater than 0."); 32 | TargetFrameRate = targetFrameRate; 33 | } 34 | 35 | /// 36 | public void Dispose() 37 | { 38 | } 39 | 40 | /// 41 | /// Ticks the frame of the current looper. 42 | /// 43 | /// 44 | public bool Tick(int frameCount) 45 | { 46 | var result = true; 47 | for (var i = 0; i < frameCount; i++) 48 | { 49 | result &= Tick(); 50 | } 51 | 52 | return result; 53 | } 54 | 55 | /// 56 | /// Ticks the frame of the current looper. 57 | /// 58 | /// 59 | public bool Tick() 60 | { 61 | var ctx = new LogicLooperActionContext(this, _frame++, Stopwatch.GetTimestamp(), TimeSpan.FromMilliseconds(1000 / TargetFrameRate) /* Fixed Time */, _ctsAction.Token); 62 | var completed = new List(); 63 | lock (_actions) 64 | { 65 | var origCurrentLogicLooper = LogicLooper.Current; 66 | try 67 | { 68 | LogicLooper.Current = this; 69 | foreach (var action in _actions.ToArray()) 70 | { 71 | if (!InvokeAction(ctx, action)) 72 | { 73 | completed.Add(action); 74 | } 75 | } 76 | 77 | foreach (var completedAction in completed) 78 | { 79 | _actions.Remove(completedAction); 80 | } 81 | 82 | return _actions.Count != 0; 83 | } 84 | finally 85 | { 86 | LogicLooper.Current = origCurrentLogicLooper; 87 | } 88 | } 89 | } 90 | 91 | /// 92 | /// Ticks the frame of the current looper while the predicate returns true. 93 | /// 94 | public void TickWhile(Func predicate) 95 | { 96 | while (predicate()) 97 | { 98 | Tick(); 99 | } 100 | } 101 | 102 | /// 103 | public Task RegisterActionAsync(LogicLooperActionDelegate loopAction) 104 | => RegisterActionAsync(loopAction, LooperActionOptions.Default); 105 | 106 | /// 107 | public Task RegisterActionAsync(LogicLooperActionDelegate loopAction, LooperActionOptions options) 108 | { 109 | var action = new LogicLooper.LooperAction(LogicLooper.DelegateHelper.GetWrapper(), loopAction, default, options, TimeProvider.System); 110 | lock (_actions) 111 | { 112 | _actions.Add(action); 113 | } 114 | return action.Future.Task; 115 | } 116 | 117 | /// 118 | public Task RegisterActionAsync(LogicLooperActionWithStateDelegate loopAction, TState state) 119 | => RegisterActionAsync(loopAction, state, LooperActionOptions.Default); 120 | 121 | /// 122 | public Task RegisterActionAsync(LogicLooperActionWithStateDelegate loopAction, TState state, LooperActionOptions options) 123 | { 124 | var action = new LogicLooper.LooperAction(LogicLooper.DelegateHelper.GetWrapper(), loopAction, state, options, TimeProvider.System); 125 | lock (_actions) 126 | { 127 | _actions.Add(action); 128 | } 129 | return action.Future.Task; 130 | } 131 | 132 | /// 133 | public Task RegisterActionAsync(LogicLooperAsyncActionDelegate loopAction) 134 | => RegisterActionAsync(loopAction, LooperActionOptions.Default); 135 | 136 | /// 137 | public Task RegisterActionAsync(LogicLooperAsyncActionDelegate loopAction, LooperActionOptions options) 138 | { 139 | var action = new LogicLooper.LooperAction(LogicLooper.DelegateHelper.GetWrapper(), LogicLooper.DelegateHelper.ConvertAsyncToSync(loopAction), default, options, TimeProvider.System); 140 | lock (_actions) 141 | { 142 | _actions.Add(action); 143 | } 144 | return action.Future.Task; 145 | } 146 | 147 | /// 148 | public Task RegisterActionAsync(LogicLooperAsyncActionWithStateDelegate loopAction, TState state) 149 | => RegisterActionAsync(loopAction, state, LooperActionOptions.Default); 150 | 151 | /// 152 | public Task RegisterActionAsync(LogicLooperAsyncActionWithStateDelegate loopAction, TState state, LooperActionOptions options) 153 | { 154 | var action = new LogicLooper.LooperAction(LogicLooper.DelegateHelper.GetWrapper(), LogicLooper.DelegateHelper.ConvertAsyncToSync(loopAction), state, options, TimeProvider.System); 155 | lock (_actions) 156 | { 157 | _actions.Add(action); 158 | } 159 | return action.Future.Task; 160 | } 161 | 162 | /// 163 | public Task ShutdownAsync(TimeSpan shutdownDelay) 164 | { 165 | return Task.CompletedTask; 166 | } 167 | 168 | private static bool InvokeAction(in LogicLooperActionContext ctx, in LogicLooper.LooperAction action) 169 | { 170 | try 171 | { 172 | var hasNext = action.Invoke(ctx); 173 | if (!hasNext) 174 | { 175 | action.Future.SetResult(true); 176 | } 177 | 178 | return hasNext; 179 | } 180 | catch (Exception ex) 181 | { 182 | action.Future.SetException(ex); 183 | } 184 | 185 | return false; 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/LogicLooper/ManualLogicLooperPool.cs: -------------------------------------------------------------------------------- 1 | namespace Cysharp.Threading; 2 | 3 | /// 4 | /// Provides a pool of . 5 | /// 6 | public sealed class ManualLogicLooperPool : ILogicLooperPool 7 | { 8 | public ManualLogicLooperPool(double targetFrameRate) 9 | { 10 | if (targetFrameRate == 0) throw new ArgumentOutOfRangeException(nameof(targetFrameRate), "TargetFrameRate must be greater than 0."); 11 | 12 | FakeLooper = new ManualLogicLooper(targetFrameRate); 13 | Loopers = new[] { FakeLooper }; 14 | } 15 | 16 | /// 17 | public IReadOnlyList Loopers { get; } 18 | 19 | /// 20 | /// Gets the fake-looper in this pool. 21 | /// 22 | public ManualLogicLooper FakeLooper { get; } 23 | 24 | /// 25 | /// Ticks the frame of the loopers. 26 | /// 27 | /// 28 | public void Tick(int frameCount) 29 | => FakeLooper.Tick(frameCount); 30 | 31 | /// 32 | /// Ticks the frame of the loopers. 33 | /// 34 | /// 35 | public bool Tick() 36 | => FakeLooper.Tick(); 37 | 38 | /// 39 | /// Ticks the frame of the loopers while the predicate returns true. 40 | /// 41 | public void TickWhile(Func predicate) 42 | => FakeLooper.TickWhile(predicate); 43 | 44 | /// 45 | public void Dispose() 46 | => Loopers[0].Dispose(); 47 | 48 | /// 49 | public Task RegisterActionAsync(LogicLooperActionDelegate loopAction) 50 | => Loopers[0].RegisterActionAsync(loopAction); 51 | 52 | /// 53 | public Task RegisterActionAsync(LogicLooperActionDelegate loopAction, LooperActionOptions options) 54 | => Loopers[0].RegisterActionAsync(loopAction, options); 55 | 56 | /// 57 | public Task RegisterActionAsync(LogicLooperActionWithStateDelegate loopAction, TState state) 58 | => Loopers[0].RegisterActionAsync(loopAction, state); 59 | 60 | /// 61 | public Task RegisterActionAsync(LogicLooperActionWithStateDelegate loopAction, TState state, LooperActionOptions options) 62 | => Loopers[0].RegisterActionAsync(loopAction, state, options); 63 | 64 | /// 65 | public Task RegisterActionAsync(LogicLooperAsyncActionDelegate loopAction) 66 | => Loopers[0].RegisterActionAsync(loopAction); 67 | 68 | /// 69 | public Task RegisterActionAsync(LogicLooperAsyncActionDelegate loopAction, LooperActionOptions options) 70 | => Loopers[0].RegisterActionAsync(loopAction, options); 71 | 72 | /// 73 | public Task RegisterActionAsync(LogicLooperAsyncActionWithStateDelegate loopAction, TState state) 74 | => Loopers[0].RegisterActionAsync(loopAction, state); 75 | 76 | /// 77 | public Task RegisterActionAsync(LogicLooperAsyncActionWithStateDelegate loopAction, TState state, LooperActionOptions options) 78 | => Loopers[0].RegisterActionAsync(loopAction, state, options); 79 | 80 | /// 81 | public Task ShutdownAsync(TimeSpan shutdownDelay) 82 | => Loopers[0].ShutdownAsync(shutdownDelay); 83 | 84 | /// 85 | public ILogicLooper GetLooper() 86 | => Loopers[0]; 87 | } 88 | -------------------------------------------------------------------------------- /src/LogicLooper/NativeMethods.txt: -------------------------------------------------------------------------------- 1 | CreateWaitableTimerExW 2 | SetWaitableTimer 3 | WaitForSingleObject 4 | CancelWaitableTimer -------------------------------------------------------------------------------- /src/LogicLooper/RoundRobinLogicLooperPoolBalancer.cs: -------------------------------------------------------------------------------- 1 | namespace Cysharp.Threading; 2 | 3 | public class RoundRobinLogicLooperPoolBalancer : ILogicLooperPoolBalancer 4 | { 5 | private int _index = 0; 6 | 7 | public static ILogicLooperPoolBalancer Instance { get; } = new RoundRobinLogicLooperPoolBalancer(); 8 | 9 | protected RoundRobinLogicLooperPoolBalancer() 10 | { } 11 | 12 | public LogicLooper GetPooledLooper(LogicLooper[] pooledLoopers) 13 | { 14 | return pooledLoopers[Interlocked.Increment(ref _index) % pooledLoopers.Length]; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/LogicLooper/opensource.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Cysharp/LogicLooper/46d3678da7a500b145a3c434fee0db4a18ebbf53/src/LogicLooper/opensource.snk -------------------------------------------------------------------------------- /test/LogicLooper.Test/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | 3 | [assembly: CollectionBehavior(CollectionBehavior.CollectionPerAssembly, DisableTestParallelization = true)] -------------------------------------------------------------------------------- /test/LogicLooper.Test/LogicLooper.Test.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | enable 6 | 1998 7 | true 8 | ..\..\src\LogicLooper\opensource.snk 9 | false 10 | true 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /test/LogicLooper.Test/LogicLooperCoroutineTest.cs: -------------------------------------------------------------------------------- 1 | using Cysharp.Threading; 2 | 3 | namespace LogicLooper.Test; 4 | 5 | public class LogicLooperCoroutineTest 6 | { 7 | [Fact] 8 | public async Task RunCoroutineNonGeneric() 9 | { 10 | using var looper = new Cysharp.Threading.LogicLooper(60); 11 | 12 | var coroutine = default(LogicLooperCoroutine); 13 | var startFrame = 0L; 14 | await looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 15 | { 16 | if (coroutine == null) 17 | { 18 | startFrame = ctx.CurrentFrame; 19 | coroutine = ctx.RunCoroutine(async ctx2 => 20 | { 21 | Assert.Equal(startFrame, ctx2.CurrentFrame); 22 | 23 | await ctx2.DelayFrame(60); 24 | 25 | Assert.Equal(startFrame + 60, ctx2.CurrentFrame); 26 | 27 | await ctx2.DelayNextFrame(); 28 | 29 | Assert.Equal(startFrame + 61, ctx2.CurrentFrame); 30 | 31 | await ctx2.Delay(TimeSpan.FromMilliseconds(16.66666)); 32 | 33 | Assert.Equal(startFrame + 62, ctx2.CurrentFrame); 34 | }); 35 | } 36 | 37 | return !coroutine.IsCompleted; 38 | }); 39 | 40 | if (coroutine.Exception != null) 41 | throw coroutine.Exception; 42 | 43 | Assert.True(coroutine.IsCompleted); 44 | Assert.True(coroutine.IsCompletedSuccessfully); 45 | Assert.False(coroutine.IsFaulted); 46 | } 47 | 48 | [Fact] 49 | public async Task RunCoroutineGeneric() 50 | { 51 | using var looper = new Cysharp.Threading.LogicLooper(60); 52 | 53 | var coroutine = default(LogicLooperCoroutine); 54 | var startFrame = 0L; 55 | await looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 56 | { 57 | if (coroutine == null) 58 | { 59 | startFrame = ctx.CurrentFrame; 60 | coroutine = ctx.RunCoroutine(async ctx2 => 61 | { 62 | Assert.Equal(startFrame, ctx2.CurrentFrame); 63 | 64 | await ctx2.DelayFrame(60); 65 | 66 | Assert.Equal(startFrame + 60, ctx2.CurrentFrame); 67 | 68 | await ctx2.DelayNextFrame(); 69 | 70 | Assert.Equal(startFrame + 61, ctx2.CurrentFrame); 71 | 72 | await ctx2.Delay(TimeSpan.FromMilliseconds(16.66666)); 73 | 74 | Assert.Equal(startFrame + 62, ctx2.CurrentFrame); 75 | 76 | return 12345; 77 | }); 78 | } 79 | 80 | return !coroutine.IsCompleted; 81 | }); 82 | 83 | if (coroutine.Exception != null) 84 | throw coroutine.Exception; 85 | 86 | Assert.True(coroutine.IsCompleted); 87 | Assert.True(coroutine.IsCompletedSuccessfully); 88 | Assert.False(coroutine.IsFaulted); 89 | 90 | Assert.Equal(12345, coroutine.Result); 91 | } 92 | 93 | [Fact] 94 | public async Task ExceptionNonGeneric() 95 | { 96 | using var looper = new Cysharp.Threading.LogicLooper(60); 97 | 98 | var coroutine = default(LogicLooperCoroutine); 99 | var startFrame = 0L; 100 | await looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 101 | { 102 | if (coroutine == null) 103 | { 104 | startFrame = ctx.CurrentFrame; 105 | coroutine = ctx.RunCoroutine(async ctx2 => 106 | { 107 | Assert.Equal(startFrame, ctx2.CurrentFrame); 108 | 109 | await ctx2.DelayFrame(5); 110 | 111 | throw new Exception("ThrownFromCoroutine"); 112 | }); 113 | } 114 | 115 | return !coroutine.IsCompleted; 116 | }); 117 | 118 | Assert.True(coroutine.IsCompleted); 119 | Assert.False(coroutine.IsCompletedSuccessfully); 120 | Assert.True(coroutine.IsFaulted); 121 | 122 | Assert.NotNull(coroutine.Exception); 123 | Assert.Equal("ThrownFromCoroutine", coroutine.Exception.Message); 124 | } 125 | 126 | [Fact] 127 | public async Task ExceptionGeneric() 128 | { 129 | using var looper = new Cysharp.Threading.LogicLooper(60); 130 | 131 | var coroutine = default(LogicLooperCoroutine); 132 | var startFrame = 0L; 133 | await looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 134 | { 135 | if (coroutine == null) 136 | { 137 | startFrame = ctx.CurrentFrame; 138 | coroutine = ctx.RunCoroutine(async ctx2 => 139 | { 140 | Assert.Equal(startFrame, ctx2.CurrentFrame); 141 | 142 | await ctx2.DelayFrame(5); 143 | 144 | throw new Exception("ThrownFromCoroutine"); 145 | 146 | #pragma warning disable CS0162 // Unreachable code detected 147 | return 1; 148 | #pragma warning restore CS0162 // Unreachable code detected 149 | }); 150 | } 151 | 152 | return !coroutine.IsCompleted; 153 | }); 154 | 155 | Assert.True(coroutine.IsCompleted); 156 | Assert.False(coroutine.IsCompletedSuccessfully); 157 | Assert.True(coroutine.IsFaulted); 158 | 159 | Assert.NotNull(coroutine.Exception); 160 | Assert.Equal("ThrownFromCoroutine", coroutine.Exception.Message); 161 | } 162 | 163 | 164 | [Fact] 165 | public async Task CoroutineLooperAlwaysSameAsParentAction() 166 | { 167 | using var looperPool = new Cysharp.Threading.LogicLooperPool(60, Environment.ProcessorCount, RoundRobinLogicLooperPoolBalancer.Instance); 168 | 169 | var loopsCount = 100; 170 | var coroutineCountPerLoop = 100; 171 | var coroutineLoopCount = 240; 172 | 173 | var tasks = new List(); 174 | var count = 0; 175 | for (var i = 0; i < loopsCount; i++) 176 | { 177 | var coroutines = default(LogicLooperCoroutine[]); 178 | var startFrame = 0L; 179 | var task = looperPool.RegisterActionAsync((in LogicLooperActionContext ctx) => 180 | { 181 | if (coroutines == null) 182 | { 183 | coroutines = new LogicLooperCoroutine[coroutineCountPerLoop]; 184 | var looper = ctx.Looper; 185 | startFrame = ctx.CurrentFrame; 186 | for (var j = 0; j < coroutineCountPerLoop; j++) 187 | { 188 | coroutines[j] = ctx.RunCoroutine(async ctx2 => 189 | { 190 | for (var k = 0; k < coroutineLoopCount; k++) 191 | { 192 | Assert.Equal(looper, ctx2.Looper); 193 | 194 | await ctx2.DelayFrame(1); 195 | 196 | Assert.Equal(looper, ctx2.Looper); 197 | 198 | Interlocked.Increment(ref count); 199 | } 200 | }); 201 | } 202 | } 203 | 204 | var faulted = coroutines.FirstOrDefault(x => x.IsFaulted); 205 | if (faulted != null) 206 | { 207 | throw faulted.Exception; 208 | } 209 | 210 | return !coroutines.All(x => x.IsCompleted); 211 | }); 212 | 213 | tasks.Add(task); 214 | } 215 | 216 | await Task.WhenAll(tasks); 217 | 218 | Assert.Equal(loopsCount * coroutineCountPerLoop * coroutineLoopCount, count); 219 | } 220 | 221 | [Fact] 222 | public async Task DelayNextFrame() 223 | { 224 | using var looper = new Cysharp.Threading.LogicLooper(60); 225 | 226 | var coroutine = default(LogicLooperCoroutine); 227 | var startFrame = 0L; 228 | await looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 229 | { 230 | if (coroutine == null) 231 | { 232 | startFrame = ctx.CurrentFrame; 233 | coroutine = ctx.RunCoroutine(async ctx2 => 234 | { 235 | Assert.Equal(startFrame, ctx2.CurrentFrame); 236 | 237 | await ctx2.DelayNextFrame(); 238 | 239 | Assert.Equal(startFrame + 1, ctx2.CurrentFrame); 240 | 241 | await ctx2.DelayNextFrame(); 242 | 243 | Assert.Equal(startFrame + 2, ctx2.CurrentFrame); 244 | }); 245 | } 246 | 247 | return !coroutine.IsCompleted; 248 | }); 249 | 250 | if (coroutine.Exception != null) 251 | throw coroutine.Exception; 252 | 253 | Assert.True(coroutine.IsCompleted); 254 | Assert.True(coroutine.IsCompletedSuccessfully); 255 | Assert.False(coroutine.IsFaulted); 256 | } 257 | 258 | [Fact] 259 | public async Task DelayFrame() 260 | { 261 | using var looper = new Cysharp.Threading.LogicLooper(60); 262 | 263 | var coroutine = default(LogicLooperCoroutine); 264 | var startFrame = 0L; 265 | await looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 266 | { 267 | if (coroutine == null) 268 | { 269 | startFrame = ctx.CurrentFrame; 270 | coroutine = ctx.RunCoroutine(async ctx2 => 271 | { 272 | Assert.Equal(startFrame, ctx2.CurrentFrame); 273 | 274 | await ctx2.DelayFrame(30); 275 | 276 | Assert.Equal(startFrame + 30, ctx2.CurrentFrame); 277 | 278 | await ctx2.DelayFrame(30); 279 | 280 | Assert.Equal(startFrame + 30 + 30, ctx2.CurrentFrame); 281 | }); 282 | } 283 | 284 | return !coroutine.IsCompleted; 285 | }); 286 | 287 | if (coroutine.Exception != null) 288 | throw coroutine.Exception; 289 | 290 | Assert.True(coroutine.IsCompleted); 291 | Assert.True(coroutine.IsCompletedSuccessfully); 292 | Assert.False(coroutine.IsFaulted); 293 | } 294 | 295 | [Fact] 296 | public async Task Delay() 297 | { 298 | using var looper = new Cysharp.Threading.LogicLooper(60); 299 | 300 | var coroutine = default(LogicLooperCoroutine); 301 | var startFrame = 0L; 302 | await looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 303 | { 304 | if (coroutine == null) 305 | { 306 | startFrame = ctx.CurrentFrame; 307 | coroutine = ctx.RunCoroutine(async ctx2 => 308 | { 309 | Assert.Equal(startFrame, ctx2.CurrentFrame); 310 | 311 | await ctx2.Delay(TimeSpan.FromMilliseconds(16.66666)); 312 | 313 | Assert.Equal(startFrame + 1, ctx2.CurrentFrame); 314 | 315 | await ctx2.Delay(TimeSpan.FromMilliseconds(33.33333)); 316 | 317 | Assert.Equal(startFrame + 1 + 2, ctx2.CurrentFrame); 318 | }); 319 | } 320 | 321 | return !coroutine.IsCompleted; 322 | }); 323 | 324 | if (coroutine.Exception != null) 325 | throw coroutine.Exception; 326 | 327 | Assert.True(coroutine.IsCompleted); 328 | Assert.True(coroutine.IsCompletedSuccessfully); 329 | Assert.False(coroutine.IsFaulted); 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /test/LogicLooper.Test/LogicLooperPoolTest.cs: -------------------------------------------------------------------------------- 1 | using Cysharp.Threading; 2 | 3 | namespace LogicLooper.Test; 4 | 5 | public class LogicLooperPoolTest 6 | { 7 | [Fact] 8 | public void Create() 9 | { 10 | using var pool = new LogicLooperPool(60, 4, RoundRobinLogicLooperPoolBalancer.Instance); 11 | Assert.Equal(4, pool.Loopers.Count()); 12 | Assert.InRange(pool.Loopers[0].TargetFrameRate, 60, 60.1); 13 | } 14 | 15 | [Fact] 16 | public void Create_TimeSpan() 17 | { 18 | using var pool = new LogicLooperPool(TimeSpan.FromMilliseconds(16.666), 4, RoundRobinLogicLooperPoolBalancer.Instance); 19 | Assert.Equal(4, pool.Loopers.Count()); 20 | Assert.InRange(pool.Loopers[0].TargetFrameRate, 60, 60.1); 21 | } 22 | 23 | [Fact] 24 | public void RegisterActionAsync() 25 | { 26 | using var pool = new LogicLooperPool(60, 4, RoundRobinLogicLooperPoolBalancer.Instance); 27 | 28 | var actionCount = 50000; 29 | var loopCount = 10; 30 | var executedCount = 0; 31 | 32 | Parallel.For(0, actionCount, _ => 33 | { 34 | var loop = 0; 35 | pool.RegisterActionAsync((in LogicLooperActionContext ctx) => 36 | { 37 | Interlocked.Increment(ref executedCount); 38 | return ++loop < loopCount; 39 | }); 40 | }); 41 | 42 | Thread.Sleep(1000); 43 | 44 | Assert.Equal(actionCount * loopCount, executedCount); 45 | } 46 | 47 | [Fact] 48 | public void GetLooper() 49 | { 50 | using var pool = new LogicLooperPool(60, 4, new FakeSequentialLogicLooperPoolBalancer()); 51 | Assert.Equal(pool.Loopers[0], pool.GetLooper()); 52 | Assert.Equal(pool.Loopers[1], pool.GetLooper()); 53 | Assert.Equal(pool.Loopers[2], pool.GetLooper()); 54 | Assert.Equal(pool.Loopers[3], pool.GetLooper()); 55 | Assert.Equal(pool.Loopers[0], pool.GetLooper()); 56 | } 57 | 58 | class FakeSequentialLogicLooperPoolBalancer : ILogicLooperPoolBalancer 59 | { 60 | private int _count; 61 | public Cysharp.Threading.LogicLooper GetPooledLooper(Cysharp.Threading.LogicLooper[] pooledLoopers) 62 | { 63 | return pooledLoopers[_count++ % pooledLoopers.Length]; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /test/LogicLooper.Test/LogicLooperSynchronizationContextTest.cs: -------------------------------------------------------------------------------- 1 | using Cysharp.Threading; 2 | using Cysharp.Threading.Internal; 3 | 4 | namespace LogicLooper.Test; 5 | 6 | public class LogicLooperSynchronizationContextTest 7 | { 8 | [Fact] 9 | public async Task Post() 10 | { 11 | using var looper = new ManualLogicLooper(60); 12 | using var syncContext = new LogicLooperSynchronizationContext(looper); 13 | 14 | var count = 0; 15 | syncContext.Post(_ => 16 | { 17 | count++; 18 | }, null); 19 | syncContext.Post(_ => 20 | { 21 | count++; 22 | }, null); 23 | syncContext.Post(_ => 24 | { 25 | count++; 26 | }, null); 27 | 28 | Assert.Equal(0, count); 29 | looper.Tick(); 30 | Assert.Equal(3, count); 31 | looper.Tick(); 32 | Assert.Equal(3, count); 33 | syncContext.Post(_ => 34 | { 35 | count++; 36 | }, null); 37 | looper.Tick(); 38 | Assert.Equal(4, count); 39 | } 40 | 41 | [Fact] 42 | public async Task LooperIntegration() 43 | { 44 | using var looper = new ManualLogicLooper(60); 45 | using var syncContext = new LogicLooperSynchronizationContext(looper); 46 | SynchronizationContext.SetSynchronizationContext(syncContext); // This context is used when advancing frame within the Tick method. Use `ConfigureAwait(false)` in the following codes. 47 | 48 | var result = new List(); 49 | var task = looper.RegisterActionAsync(async (ctx) => 50 | { 51 | result.Add("1"); // Frame: 1 52 | await Task.Delay(250); 53 | result.Add("2"); // Frame: 2 54 | return false; 55 | }); 56 | 57 | looper.Tick(); 58 | Assert.Equal(new[] { "1" }, result); 59 | 60 | await Task.Delay(500).ConfigureAwait(false); 61 | 62 | looper.Tick(); // Run continuation 63 | looper.Tick(); // Wait for complete action 64 | Assert.Equal(new[] { "1", "2" }, result); 65 | 66 | Assert.True(task.IsCompleted); 67 | } 68 | 69 | [Fact] 70 | public async Task DequeueLoopAction_NotRegisteredWhenNonAsync() 71 | { 72 | using var looper = new ManualLogicLooper(60); 73 | using var syncContext = new LogicLooperSynchronizationContext(looper); 74 | SynchronizationContext.SetSynchronizationContext(syncContext); // This context is used when advancing frame within the Tick method. Use `ConfigureAwait(false)` in the following codes. 75 | var t = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => false); 76 | 77 | Assert.Equal(1, looper.ApproximatelyRunningActions); 78 | looper.Tick(); 79 | Assert.Equal(0, looper.ApproximatelyRunningActions); 80 | Assert.True(t.IsCompleted); 81 | } 82 | 83 | [Fact] 84 | public async Task DequeueLoopAction_RegisteredWhenHasAsyncAction() 85 | { 86 | using var looper = new ManualLogicLooper(60); 87 | using var syncContext = new LogicLooperSynchronizationContext(looper); 88 | SynchronizationContext.SetSynchronizationContext(syncContext); // This context is used when advancing frame within the Tick method. Use `ConfigureAwait(false)` in the following codes. 89 | var t = looper.RegisterActionAsync(async (LogicLooperActionContext ctx) => 90 | { 91 | await Task.Yield(); 92 | return false; 93 | }); 94 | 95 | Assert.Equal(1, looper.ApproximatelyRunningActions); // User-Action 96 | looper.Tick(); 97 | await Task.Delay(100).ConfigureAwait(false); 98 | Assert.Equal(2, looper.ApproximatelyRunningActions); // User-Action + DequeLoopAction 99 | looper.Tick(); // Run continuation 100 | looper.Tick(); // Wait for complete action 101 | Assert.True(t.IsCompleted); 102 | Assert.Equal(1, looper.ApproximatelyRunningActions); // DequeueLoopAction 103 | } 104 | 105 | } 106 | -------------------------------------------------------------------------------- /test/LogicLooper.Test/LogicLooperTest.cs: -------------------------------------------------------------------------------- 1 | using Cysharp.Threading; 2 | using Cysharp.Threading.Internal; 3 | 4 | namespace LogicLooper.Test; 5 | 6 | public class LogicLooperTest 7 | { 8 | [Theory] 9 | [InlineData(16.6666)] // 60fps 10 | [InlineData(33.3333)] // 30fps 11 | public async Task TargetFrameTime(double targetFrameTimeMs) 12 | { 13 | using var looper = new Cysharp.Threading.LogicLooper(TimeSpan.FromMilliseconds(targetFrameTimeMs)); 14 | 15 | Assert.Equal(0, looper.ApproximatelyRunningActions); 16 | Assert.Equal(1000 / (double)targetFrameTimeMs, looper.TargetFrameRate); 17 | 18 | var beginTimestamp = DateTime.Now.Ticks; 19 | var lastTimestamp = beginTimestamp; 20 | var fps = 0d; 21 | var task = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 22 | { 23 | var now = DateTime.Now.Ticks; 24 | var elapsedFromBeginMilliseconds = (now - beginTimestamp) / TimeSpan.TicksPerMillisecond; 25 | var elapsedFromPreviousFrameMilliseconds = (now - lastTimestamp) / TimeSpan.TicksPerMillisecond; 26 | 27 | if (elapsedFromPreviousFrameMilliseconds == 0) return true; 28 | 29 | fps = (fps + (1000 / elapsedFromPreviousFrameMilliseconds)) / 2d; 30 | 31 | lastTimestamp = now; 32 | 33 | return elapsedFromBeginMilliseconds < 3000; // 3 seconds 34 | }); 35 | 36 | // wait for moving action from queue to actions. 37 | await Task.Delay(100); 38 | 39 | Assert.Equal(1, looper.ApproximatelyRunningActions); 40 | 41 | await task; 42 | 43 | await Task.Delay(100); 44 | 45 | Assert.Equal(0, looper.ApproximatelyRunningActions); 46 | 47 | Assert.InRange(fps, looper.TargetFrameRate - 2, looper.TargetFrameRate + 2); 48 | } 49 | 50 | [Theory] 51 | [InlineData(60)] 52 | [InlineData(30)] 53 | [InlineData(20)] 54 | public async Task TargetFrameRate_1(int targetFps) 55 | { 56 | using var looper = new Cysharp.Threading.LogicLooper(targetFps); 57 | 58 | Assert.Equal(0, looper.ApproximatelyRunningActions); 59 | Assert.Equal(targetFps, ((int)looper.TargetFrameRate)); 60 | 61 | var beginTimestamp = DateTime.Now.Ticks; 62 | var lastTimestamp = beginTimestamp; 63 | var fps = 0d; 64 | var task = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 65 | { 66 | var now = DateTime.Now.Ticks; 67 | var elapsedFromBeginMilliseconds = (now - beginTimestamp) / TimeSpan.TicksPerMillisecond; 68 | var elapsedFromPreviousFrameMilliseconds = (now - lastTimestamp) / TimeSpan.TicksPerMillisecond; 69 | 70 | if (elapsedFromPreviousFrameMilliseconds == 0) return true; 71 | 72 | fps = (fps + (1000 / elapsedFromPreviousFrameMilliseconds)) / 2d; 73 | 74 | lastTimestamp = now; 75 | 76 | return elapsedFromBeginMilliseconds < 3000; // 3 seconds 77 | }); 78 | 79 | // wait for moving action from queue to actions. 80 | await Task.Delay(100); 81 | 82 | Assert.Equal(1, looper.ApproximatelyRunningActions); 83 | 84 | await task; 85 | 86 | await Task.Delay(100); 87 | 88 | Assert.Equal(0, looper.ApproximatelyRunningActions); 89 | 90 | Assert.InRange(fps, targetFps - 2, targetFps + 2); 91 | } 92 | 93 | [Fact] 94 | public async Task Exit() 95 | { 96 | using var looper = new Cysharp.Threading.LogicLooper(60); 97 | 98 | var count = 0; 99 | await looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 100 | { 101 | count++; 102 | return false; 103 | }); 104 | 105 | await Task.Delay(100); 106 | Assert.Equal(1, count); 107 | } 108 | 109 | [Fact] 110 | public async Task Throw() 111 | { 112 | using var looper = new Cysharp.Threading.LogicLooper(60); 113 | 114 | var count = 0; 115 | await Assert.ThrowsAsync(() => looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 116 | { 117 | count++; 118 | throw new Exception("Throw from inside loop"); 119 | })); 120 | 121 | await Task.Delay(100); 122 | Assert.Equal(1, count); 123 | } 124 | 125 | [Fact] 126 | public async Task CurrentFrame() 127 | { 128 | using var looper = new Cysharp.Threading.LogicLooper(60); 129 | 130 | var currentFrame = 0L; 131 | await looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 132 | { 133 | currentFrame = ctx.CurrentFrame; 134 | return currentFrame != 10; 135 | }); 136 | 137 | await Task.Delay(100); 138 | Assert.Equal(10, currentFrame); 139 | } 140 | 141 | [Fact] 142 | public async Task Shutdown_Delay_Cancel() 143 | { 144 | using var looper = new Cysharp.Threading.LogicLooper(60); 145 | 146 | var runLoopTask = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => !ctx.CancellationToken.IsCancellationRequested); 147 | Assert.False(runLoopTask.IsCompleted); 148 | 149 | var shutdownTask = looper.ShutdownAsync(TimeSpan.FromMilliseconds(500)); 150 | await Task.Delay(50); 151 | Assert.True(runLoopTask.IsCompleted); 152 | Assert.False(shutdownTask.IsCompleted); 153 | 154 | await shutdownTask; 155 | } 156 | 157 | 158 | [Fact] 159 | public async Task Shutdown_Delay_Cancel_2() 160 | { 161 | using var looper = new Cysharp.Threading.LogicLooper(60); 162 | 163 | var count = 0; 164 | var runLoopTask = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 165 | { 166 | count++; 167 | return !ctx.CancellationToken.IsCancellationRequested; 168 | }); 169 | Assert.False(runLoopTask.IsCompleted); 170 | 171 | var shutdownTask = looper.ShutdownAsync(TimeSpan.FromMilliseconds(500)); 172 | await Task.Delay(50); 173 | Assert.True(runLoopTask.IsCompleted); 174 | var count2 = count; 175 | Assert.False(shutdownTask.IsCompleted); 176 | 177 | await shutdownTask; 178 | Assert.Equal(count, count); 179 | } 180 | 181 | [Fact] 182 | public async Task Shutdown_Immediately() 183 | { 184 | using var looper = new Cysharp.Threading.LogicLooper(1); 185 | 186 | var signal = new ManualResetEventSlim(); 187 | var runLoopTask = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 188 | { 189 | signal.Set(); 190 | return !ctx.CancellationToken.IsCancellationRequested; 191 | }); 192 | Assert.False(runLoopTask.IsCompleted); 193 | 194 | signal.Wait(); 195 | var shutdownTask = looper.ShutdownAsync(TimeSpan.Zero); 196 | await shutdownTask; 197 | 198 | //runLoopTask.IsCompleted.Should().BeFalse(); // When the looper thread is waiting for next cycle, the loop task should not be completed. 199 | } 200 | 201 | 202 | [Fact] 203 | public async Task LastProcessingDuration() 204 | { 205 | using var looper = new Cysharp.Threading.LogicLooper(60); 206 | 207 | var runLoopTask = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 208 | { 209 | SleepInterop.Sleep(100); 210 | return !ctx.CancellationToken.IsCancellationRequested; 211 | }); 212 | 213 | await Task.Delay(1000); 214 | await looper.ShutdownAsync(TimeSpan.Zero); 215 | 216 | Assert.InRange(looper.LastProcessingDuration.TotalMilliseconds, 95, 105); 217 | } 218 | 219 | [Fact] 220 | public async Task AsyncAction() 221 | { 222 | using var looper = new Cysharp.Threading.LogicLooper(60); 223 | var count = 0; 224 | var managedThreadId = 0; 225 | await looper.RegisterActionAsync(((in LogicLooperActionContext ctx) => 226 | { 227 | managedThreadId = Thread.CurrentThread.ManagedThreadId; 228 | return false; 229 | })); 230 | 231 | var results = new List(); 232 | var runLoopTask = looper.RegisterActionAsync(async (LogicLooperActionContext ctx) => 233 | { 234 | results.Add(Thread.CurrentThread.ManagedThreadId); 235 | await Task.Delay(100); 236 | results.Add(Thread.CurrentThread.ManagedThreadId); 237 | return ++count < 3; 238 | }); 239 | 240 | await runLoopTask; 241 | 242 | // 2 x 3 243 | Assert.Equal(new[] { managedThreadId, managedThreadId, managedThreadId, managedThreadId, managedThreadId, managedThreadId }, results); 244 | } 245 | 246 | [Fact] 247 | public async Task AsyncAction_WithState() 248 | { 249 | using var looper = new Cysharp.Threading.LogicLooper(60); 250 | var managedThreadId = 0; 251 | await looper.RegisterActionAsync(((in LogicLooperActionContext ctx) => 252 | { 253 | managedThreadId = Thread.CurrentThread.ManagedThreadId; 254 | return false; 255 | })); 256 | 257 | var results = new List(); 258 | var runLoopTask = looper.RegisterActionAsync(static async (LogicLooperActionContext ctx, List results) => 259 | { 260 | await Task.Delay(100); 261 | results.Add(Thread.CurrentThread.ManagedThreadId); 262 | return results.Count < 3; 263 | }, results); 264 | 265 | await runLoopTask; 266 | 267 | Assert.Equal(new[] { managedThreadId, managedThreadId, managedThreadId }, results); 268 | } 269 | 270 | [Fact] 271 | public async Task AsyncAction_Fault() 272 | { 273 | using var looper = new Cysharp.Threading.LogicLooper(60); 274 | var count = 0; 275 | var runLoopTask = looper.RegisterActionAsync(async (LogicLooperActionContext ctx) => 276 | { 277 | count++; 278 | await Task.Delay(100); 279 | throw new InvalidOperationException(); 280 | }); 281 | 282 | await Assert.ThrowsAsync(async () => await runLoopTask); 283 | await Task.Delay(100); 284 | Assert.Equal(1, count); 285 | } 286 | 287 | [Theory] 288 | [InlineData(60, 10)] 289 | [InlineData(30, 10)] 290 | [InlineData(20, 10)] 291 | public async Task TargetFrameRateOverride_1(int targetFps, int overrideTargetFps) 292 | { 293 | using var looper = new Cysharp.Threading.LogicLooper(targetFps); 294 | 295 | Assert.Equal(0, looper.ApproximatelyRunningActions); 296 | Assert.Equal(targetFps, ((int)looper.TargetFrameRate)); 297 | 298 | var beginTimestamp = DateTime.Now.Ticks; 299 | var lastTimestamp = beginTimestamp; 300 | var fps = 0d; 301 | 302 | var lastFrameNum = 0L; 303 | var frameCount = -1L; // CurrentFrame will be started from 0 304 | var task = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 305 | { 306 | frameCount++; 307 | lastFrameNum = ctx.CurrentFrame; 308 | 309 | var now = DateTime.Now.Ticks; 310 | var elapsedFromBeginMilliseconds = (now - beginTimestamp) / TimeSpan.TicksPerMillisecond; 311 | var elapsedFromPreviousFrameMilliseconds = (now - lastTimestamp) / TimeSpan.TicksPerMillisecond; 312 | 313 | if (elapsedFromPreviousFrameMilliseconds == 0) return true; 314 | 315 | fps = (fps + (1000 / elapsedFromPreviousFrameMilliseconds)) / 2d; 316 | 317 | lastTimestamp = now; 318 | 319 | return elapsedFromBeginMilliseconds < 3000; // 3 seconds 320 | }, LooperActionOptions.Default with { TargetFrameRateOverride = overrideTargetFps }); 321 | 322 | // wait for moving action from queue to actions. 323 | await Task.Delay(100); 324 | 325 | Assert.Equal(1, looper.ApproximatelyRunningActions); 326 | 327 | await task; 328 | 329 | await Task.Delay(100); 330 | 331 | Assert.Equal(0, looper.ApproximatelyRunningActions); 332 | 333 | Assert.Equal(lastFrameNum, frameCount); 334 | Assert.InRange(fps, overrideTargetFps - 2, overrideTargetFps + 2); 335 | } 336 | 337 | [Fact] 338 | public async Task TargetFrameRateOverride_Invalid() 339 | { 340 | using var looper = new Cysharp.Threading.LogicLooper(30); 341 | 342 | await Assert.ThrowsAsync(async () => await looper.RegisterActionAsync((in LogicLooperActionContext _) => false, LooperActionOptions.Default with { TargetFrameRateOverride = 31 })); 343 | } 344 | 345 | [Fact] 346 | public async Task TargetFrameRateOverride_2() 347 | { 348 | using var looper = new Cysharp.Threading.LogicLooper(30); 349 | 350 | Assert.Equal(0, looper.ApproximatelyRunningActions); 351 | 352 | var lastFrameNum = 0L; 353 | var overriddenFrameCount = -1L; 354 | var cts = new CancellationTokenSource(); 355 | 356 | _ = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 357 | { 358 | return ctx.CurrentFrame != 4; 359 | }); 360 | _ = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 361 | { 362 | return ctx.CurrentFrame != 3; 363 | }); 364 | _ = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 365 | { 366 | return ctx.CurrentFrame != 2; 367 | }); 368 | _ = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 369 | { 370 | return ctx.CurrentFrame != 1; 371 | }); 372 | 373 | var task = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 374 | { 375 | overriddenFrameCount++; 376 | return !cts.IsCancellationRequested; 377 | }, LooperActionOptions.Default with { TargetFrameRateOverride = 1 /* 1 frame per second */ }); 378 | 379 | // wait for moving action from queue to actions. 380 | await Task.Delay(1100); 381 | cts.Cancel(); 382 | 383 | Assert.Equal(1, looper.ApproximatelyRunningActions); 384 | 385 | Assert.Equal(1, overriddenFrameCount); 386 | } 387 | } 388 | -------------------------------------------------------------------------------- /test/LogicLooper.Test/ManualLogicLooperPoolTest.cs: -------------------------------------------------------------------------------- 1 | using Cysharp.Threading; 2 | 3 | namespace LogicLooper.Test; 4 | 5 | public class ManualLogicLooperPoolTest 6 | { 7 | [Fact] 8 | public void Create() 9 | { 10 | var pool = new ManualLogicLooperPool(60.0); 11 | Assert.Single(pool.Loopers); 12 | Assert.Equal(60.0, pool.FakeLooper.TargetFrameRate); 13 | } 14 | 15 | [Fact] 16 | public void RegisterActionAsync() 17 | { 18 | var pool = new ManualLogicLooperPool(60.0); 19 | 20 | var t1 = pool.RegisterActionAsync((in LogicLooperActionContext ctx) => 21 | { 22 | return false; 23 | }); 24 | Assert.Equal(1, pool.Loopers[0].ApproximatelyRunningActions); 25 | pool.Tick(); 26 | Assert.Equal(0, pool.Loopers[0].ApproximatelyRunningActions); 27 | Assert.True(t1.IsCompletedSuccessfully); 28 | } 29 | 30 | [Fact] 31 | public void GetLooper() 32 | { 33 | var pool = new ManualLogicLooperPool(60.0); 34 | var looper = pool.GetLooper(); 35 | 36 | Assert.Equal(pool.FakeLooper, looper); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /test/LogicLooper.Test/ManualLogicLooperTest.cs: -------------------------------------------------------------------------------- 1 | using Cysharp.Threading; 2 | 3 | namespace LogicLooper.Test; 4 | 5 | public class ManualLogicLooperTest 6 | { 7 | [Fact] 8 | public void Elapsed() 9 | { 10 | var looper = new ManualLogicLooper(60.0); 11 | Assert.Equal(60.0, looper.TargetFrameRate); 12 | 13 | var elapsed = default(TimeSpan); 14 | looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 15 | { 16 | elapsed = ctx.ElapsedTimeFromPreviousFrame; 17 | return false; 18 | }); 19 | looper.Tick(); 20 | 21 | Assert.InRange(elapsed.TotalMilliseconds, 16.6666, 16.9999); 22 | } 23 | 24 | [Fact] 25 | public void Elapsed_2() 26 | { 27 | var looper = new ManualLogicLooper(30.0); 28 | Assert.Equal(30.0, looper.TargetFrameRate); 29 | 30 | var elapsed = default(TimeSpan); 31 | looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 32 | { 33 | elapsed = ctx.ElapsedTimeFromPreviousFrame; 34 | return false; 35 | }); 36 | looper.Tick(); 37 | 38 | Assert.InRange(elapsed.TotalMilliseconds, 33.3333, 33.9999); 39 | } 40 | 41 | [Fact] 42 | public void Tick() 43 | { 44 | var looper = new ManualLogicLooper(60.0); 45 | Assert.Equal(0, looper.ApproximatelyRunningActions); 46 | 47 | var count = 0; 48 | var t1 = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 49 | { 50 | count++; 51 | return count != 3; 52 | }); 53 | Assert.Equal(1, looper.ApproximatelyRunningActions); 54 | 55 | Assert.Equal(0, count); 56 | Assert.Equal(0, looper.CurrentFrame); 57 | Assert.True(looper.Tick()); 58 | Assert.Equal(1, count); 59 | Assert.Equal(1, looper.CurrentFrame); 60 | Assert.True(looper.Tick()); 61 | Assert.Equal(2, count); 62 | Assert.Equal(2, looper.CurrentFrame); 63 | Assert.False(looper.Tick()); 64 | Assert.Equal(3, count); 65 | Assert.Equal(3, looper.CurrentFrame); 66 | 67 | Assert.Equal(0, looper.ApproximatelyRunningActions); 68 | Assert.True(t1.IsCompletedSuccessfully); 69 | } 70 | 71 | [Fact] 72 | public void Tick_Multiple() 73 | { 74 | var looper = new ManualLogicLooper(60.0); 75 | Assert.Equal(0, looper.ApproximatelyRunningActions); 76 | 77 | var count = 0; 78 | var t1 = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 79 | { 80 | count++; 81 | return count != 5; 82 | }); 83 | var t2 = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 84 | { 85 | count++; 86 | return count != 7; 87 | }); 88 | Assert.Equal(2, looper.ApproximatelyRunningActions); 89 | 90 | Assert.Equal(0, count); 91 | Assert.True(looper.Tick()); 92 | Assert.Equal(2, count); 93 | Assert.True(looper.Tick()); 94 | Assert.Equal(4, count); 95 | Assert.True(looper.Tick()); 96 | Assert.Equal(6, count); 97 | Assert.Equal(1, looper.ApproximatelyRunningActions); 98 | Assert.True(t1.IsCompletedSuccessfully); 99 | 100 | Assert.False(looper.Tick()); 101 | Assert.Equal(7, count); 102 | 103 | Assert.Equal(0, looper.ApproximatelyRunningActions); 104 | Assert.True(t2.IsCompletedSuccessfully); 105 | } 106 | 107 | [Fact] 108 | public void Tick_Count() 109 | { 110 | var looper = new ManualLogicLooper(60.0); 111 | Assert.Equal(0, looper.ApproximatelyRunningActions); 112 | 113 | var count = 0; 114 | var t1 = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 115 | { 116 | count++; 117 | return count != 3; 118 | }); 119 | Assert.Equal(1, looper.ApproximatelyRunningActions); 120 | 121 | Assert.False(looper.Tick(3)); 122 | 123 | Assert.Equal(0, looper.ApproximatelyRunningActions); 124 | Assert.True(t1.IsCompletedSuccessfully); 125 | } 126 | 127 | [Fact] 128 | public void TickWhile() 129 | { 130 | var looper = new ManualLogicLooper(60.0); 131 | Assert.Equal(0, looper.ApproximatelyRunningActions); 132 | 133 | var count = 0; 134 | var t1 = looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 135 | { 136 | count++; 137 | return true; 138 | }); 139 | Assert.Equal(1, looper.ApproximatelyRunningActions); 140 | 141 | Assert.Equal(0, count); 142 | 143 | looper.TickWhile(() => count != 6); 144 | 145 | Assert.Equal(6, count); 146 | 147 | Assert.Equal(1, looper.ApproximatelyRunningActions); 148 | Assert.False(t1.IsCompletedSuccessfully); 149 | } 150 | 151 | 152 | [Fact] 153 | public void RegisterActionAsync_State() 154 | { 155 | var looper = new ManualLogicLooper(60.0); 156 | Assert.Equal(0, looper.ApproximatelyRunningActions); 157 | 158 | var count = 0; 159 | var tuple = Tuple.Create("Foo", 123); 160 | var receivedState = default(Tuple); 161 | var t1 = looper.RegisterActionAsync((in LogicLooperActionContext ctx, Tuple state) => 162 | { 163 | receivedState = state; 164 | count++; 165 | return count != 3; 166 | }, tuple); 167 | Assert.Equal(1, looper.ApproximatelyRunningActions); 168 | 169 | Assert.True(looper.Tick()); 170 | Assert.True(looper.Tick()); 171 | Assert.False(looper.Tick()); 172 | Assert.Equal(3, count); 173 | Assert.Equal(tuple, receivedState); 174 | 175 | Assert.Equal(0, looper.ApproximatelyRunningActions); 176 | Assert.True(t1.IsCompletedSuccessfully); 177 | } 178 | 179 | [Fact] 180 | public void LogicLooper_Current() 181 | { 182 | Assert.Null(Cysharp.Threading.LogicLooper.Current); 183 | 184 | var looper = new ManualLogicLooper(60.0); 185 | Assert.Equal(60.0, looper.TargetFrameRate); 186 | 187 | var currentLogicLooperInAction = default(ILogicLooper); 188 | looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 189 | { 190 | currentLogicLooperInAction = Cysharp.Threading.LogicLooper.Current; 191 | return false; 192 | }); 193 | looper.Tick(); 194 | 195 | Assert.Equal(looper, currentLogicLooperInAction); 196 | Assert.Null(Cysharp.Threading.LogicLooper.Current); 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /test/LogicLooper.Test/SleepInteropTest.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using Cysharp.Threading.Internal; 3 | 4 | namespace LogicLooper.Test; 5 | 6 | public class SleepInteropTest 7 | { 8 | [Fact] 9 | public void LessThan16Milliseconds() 10 | { 11 | var begin = Stopwatch.GetTimestamp(); 12 | SleepInterop.Sleep(1); 13 | var end = Stopwatch.GetTimestamp(); 14 | var elapsed = Stopwatch.GetElapsedTime(begin, end); 15 | Assert.True(elapsed.TotalMilliseconds < 16); 16 | } 17 | 18 | [Fact] 19 | public void GreaterThan16Milliseconds() 20 | { 21 | var begin = Stopwatch.GetTimestamp(); 22 | SleepInterop.Sleep(17); 23 | var end = Stopwatch.GetTimestamp(); 24 | var elapsed = Stopwatch.GetElapsedTime(begin, end); 25 | Assert.True(elapsed.TotalMilliseconds > 16); 26 | } 27 | 28 | [Fact] 29 | public void ThreadSafety() 30 | { 31 | var threads = Enumerable.Range(0, 10).Select(x => 32 | { 33 | var t = new Thread(() => 34 | { 35 | for (var i = 0; i < 10; i++) 36 | { 37 | SleepInterop.Sleep(100); 38 | } 39 | }); 40 | t.Start(); 41 | return t; 42 | }).ToArray(); 43 | foreach (var thread in threads) 44 | { 45 | Assert.True(thread.Join(TimeSpan.FromSeconds(10))); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/LogicLooper.Test/StressTest.cs: -------------------------------------------------------------------------------- 1 | using Cysharp.Threading; 2 | 3 | namespace LogicLooper.Test; 4 | 5 | public class StressTest 6 | { 7 | [Theory] 8 | [InlineData(60, 1000000, 100)] 9 | [InlineData(120, 1000000, 1)] 10 | [InlineData(120, 10000, 1000)] 11 | public void LogicLooperPool_Stress_1(int targetFps, int actionCount, int loopCount) 12 | { 13 | using var pool = new LogicLooperPool(targetFps, 4, RoundRobinLogicLooperPoolBalancer.Instance); 14 | 15 | var executedCount = 0; 16 | var launchedCount = 0; 17 | 18 | var begin = DateTime.Now; 19 | Parallel.For(0, actionCount, _ => 20 | { 21 | var firstTime = true; 22 | var loop = 0; 23 | pool.RegisterActionAsync((in LogicLooperActionContext ctx) => 24 | { 25 | if (firstTime) 26 | { 27 | Interlocked.Increment(ref launchedCount); 28 | firstTime = false; 29 | } 30 | Interlocked.Increment(ref executedCount); 31 | return ++loop < loopCount; 32 | }); 33 | }); 34 | 35 | while (true) 36 | { 37 | var elapsed = DateTime.Now - begin; 38 | Assert.True(elapsed.TotalSeconds < 20, "Timed out"); 39 | 40 | if (executedCount >= actionCount * loopCount) 41 | { 42 | break; 43 | } 44 | 45 | Thread.Sleep(100); 46 | } 47 | 48 | Assert.Equal(actionCount, launchedCount); 49 | Assert.Equal(actionCount * loopCount, executedCount); 50 | } 51 | 52 | [Theory] 53 | [InlineData(60, 1000000, 100)] 54 | [InlineData(120, 1000000, 1)] 55 | [InlineData(120, 10000, 1000)] 56 | public async Task LogicLooper_Stress_1(int targetFps, int actionCount, int loopCount) 57 | { 58 | using var looper = new Cysharp.Threading.LogicLooper(targetFps); 59 | 60 | Assert.Equal(0, looper.ApproximatelyRunningActions); 61 | Assert.InRange(looper.TargetFrameRate, targetFps - 1, targetFps + 1); 62 | 63 | var executedCount = 0; 64 | var launchedCount = 0; 65 | 66 | var begin = DateTime.Now; 67 | Parallel.For(0, actionCount, _ => 68 | { 69 | var firstTime = true; 70 | var loop = 0; 71 | looper.RegisterActionAsync((in LogicLooperActionContext ctx) => 72 | { 73 | // the looper uses fixed-thread and loop action in single-thread. 74 | loop++; 75 | executedCount++; 76 | 77 | if (firstTime) 78 | { 79 | launchedCount++; 80 | firstTime = false; 81 | } 82 | 83 | return loop < loopCount; 84 | }); 85 | }); 86 | 87 | while (true) 88 | { 89 | var elapsed = DateTime.Now - begin; 90 | Assert.True(elapsed.TotalSeconds < 20, "Timed out"); 91 | 92 | if (executedCount >= actionCount * loopCount) 93 | { 94 | break; 95 | } 96 | Thread.Sleep(100); 97 | } 98 | 99 | Assert.Equal(actionCount, launchedCount); 100 | Assert.Equal(actionCount * loopCount, executedCount); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /test/LogicLooper.Test/Usings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; 2 | --------------------------------------------------------------------------------
RunningActions: @Model.RunningActions