claude-code-arcane 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/README.md +104 -55
- package/agents/career/career-strategist.md +88 -0
- package/agents/career/cv-reviewer.md +81 -0
- package/agents/career/hiring-manager.md +68 -0
- package/agents/career/mock-interviewer.md +76 -0
- package/agents/freelance/client-screener.md +84 -0
- package/agents/freelance/discovery-call.md +65 -0
- package/agents/freelance/pipeline-strategist.md +99 -0
- package/agents/freelance/proposal-reviewer.md +82 -0
- package/agents/game/ue-blueprint-specialist.md +125 -0
- package/agents/game/ue-gas-specialist.md +133 -0
- package/agents/game/ue-umg-specialist.md +131 -0
- package/dist/cli.js +57 -13
- package/docs/RELEASE-SETUP.md +1 -1
- package/docs/SKILLS-CATALOG.md +67 -4
- package/docs/USER-GUIDE.md +77 -72
- package/docs/agent-hierarchy.md +21 -1
- package/docs/presentations/build_arcane_deck.py +2 -2
- package/docs/presentations/build_arcane_deck_12.py +2 -2
- package/hooks/detect-unreal.sh +90 -0
- package/package.json +2 -2
- package/profiles/freelance.yaml +43 -0
- package/profiles/job-hunt.yaml +5 -2
- package/profiles/unreal-dev.yaml +97 -0
- package/rules/drafter-reviewer.md +16 -3
- package/rules/freelance-guardrails.md +53 -0
- package/rules/gamedev/unreal-code.md +125 -0
- package/skills/career-registry/SKILL.md +95 -0
- package/skills/career-registry/scripts/career_registry.py +488 -0
- package/skills/client-screen/SKILL.md +83 -0
- package/skills/client-screen/references/red-flags.md +95 -0
- package/skills/cover-letter/SKILL.md +4 -3
- package/skills/cv-tailor/SKILL.md +11 -2
- package/skills/freelance-deliver/SKILL.md +90 -0
- package/skills/freelance-hunt/SKILL.md +126 -0
- package/skills/freelance-hunt/references/templates/Connects.md +39 -0
- package/skills/freelance-hunt/references/templates/Contrato.md +60 -0
- package/skills/freelance-hunt/references/templates/Propuesta.md +66 -0
- package/skills/freelance-kickoff/SKILL.md +94 -0
- package/skills/freelance-pipeline/SKILL.md +109 -0
- package/skills/freelance-pipeline/scripts/import_history.py +425 -0
- package/skills/freelance-profile/SKILL.md +93 -0
- package/skills/freelance-proposal/SKILL.md +112 -0
- package/skills/freelance-scan/SKILL.md +196 -0
- package/skills/freelance-scan/references/dedup.md +35 -0
- package/skills/freelance-scan/references/platforms.md +167 -0
- package/skills/freelance-scan/scripts/freelance_search.py +1077 -0
- package/skills/install-mcp/SKILL.md +135 -46
- package/skills/install-mcp/references/unreal-operations.md +55 -0
- package/skills/interview-prep/SKILL.md +11 -3
- package/skills/job-aplicar/SKILL.md +5 -1
- package/skills/job-hunt/SKILL.md +17 -0
- package/skills/job-outcome/SKILL.md +15 -1
- package/skills/job-upskill/SKILL.md +10 -2
- package/skills/ue-actor-component-architecture/SKILL.md +509 -0
- package/skills/ue-actor-component-architecture/references/actor-lifecycle.md +301 -0
- package/skills/ue-actor-component-architecture/references/component-types.md +592 -0
- package/skills/ue-ai-navigation/SKILL.md +526 -0
- package/skills/ue-ai-navigation/references/behavior-tree-patterns.md +392 -0
- package/skills/ue-ai-navigation/references/behavior-tree-workflow.md +107 -0
- package/skills/ue-ai-navigation/references/custom-bttask.md +104 -0
- package/skills/ue-ai-navigation/references/eqs-reference.md +423 -0
- package/skills/ue-animation-system/SKILL.md +521 -0
- package/skills/ue-animation-system/references/anim-notify-reference.md +486 -0
- package/skills/ue-animation-system/references/locomotion-setup.md +550 -0
- package/skills/ue-async-threading/SKILL.md +433 -0
- package/skills/ue-async-threading/references/thread-safety-guide.md +213 -0
- package/skills/ue-async-threading/references/threading-patterns.md +345 -0
- package/skills/ue-audio-system/SKILL.md +527 -0
- package/skills/ue-audio-system/references/audio-setup-patterns.md +700 -0
- package/skills/ue-blueprints/SKILL.md +134 -0
- package/skills/ue-blueprints/references/communication.md +82 -0
- package/skills/ue-character-movement/SKILL.md +475 -0
- package/skills/ue-character-movement/references/cmc-extension-patterns.md +335 -0
- package/skills/ue-character-movement/references/movement-pipeline.md +189 -0
- package/skills/ue-cpp-foundations/SKILL.md +533 -0
- package/skills/ue-cpp-foundations/references/components-and-gc.md +107 -0
- package/skills/ue-cpp-foundations/references/container-patterns.md +544 -0
- package/skills/ue-cpp-foundations/references/delegate-patterns.md +497 -0
- package/skills/ue-cpp-foundations/references/gameplay-classes-cpp.md +136 -0
- package/skills/ue-cpp-foundations/references/property-specifiers.md +306 -0
- package/skills/ue-data-assets-tables/SKILL.md +527 -0
- package/skills/ue-data-assets-tables/references/asset-loading-patterns.md +306 -0
- package/skills/ue-data-assets-tables/references/data-driven-design.md +378 -0
- package/skills/ue-editor-tools/SKILL.md +523 -0
- package/skills/ue-editor-tools/references/detail-customization-patterns.md +395 -0
- package/skills/ue-editor-tools/references/editor-module-setup.md +480 -0
- package/skills/ue-game-features/SKILL.md +477 -0
- package/skills/ue-game-features/references/experience-system.md +222 -0
- package/skills/ue-game-features/references/game-feature-patterns.md +308 -0
- package/skills/ue-gameplay-abilities/SKILL.md +518 -0
- package/skills/ue-gameplay-abilities/references/ability-task-reference.md +391 -0
- package/skills/ue-gameplay-abilities/references/gameplay-effect-reference.md +397 -0
- package/skills/ue-gameplay-abilities/references/gas-setup-patterns.md +369 -0
- package/skills/ue-gameplay-framework/SKILL.md +466 -0
- package/skills/ue-gameplay-framework/references/framework-class-map.md +243 -0
- package/skills/ue-input-system/SKILL.md +394 -0
- package/skills/ue-input-system/references/cpp-setup.md +131 -0
- package/skills/ue-input-system/references/enhanced-input-workflow.md +117 -0
- package/skills/ue-input-system/references/input-action-reference.md +731 -0
- package/skills/ue-mass-entity/SKILL.md +476 -0
- package/skills/ue-mass-entity/references/mass-entity-patterns.md +319 -0
- package/skills/ue-mass-entity/references/mass-fragment-reference.md +119 -0
- package/skills/ue-materials-rendering/SKILL.md +526 -0
- package/skills/ue-materials-rendering/references/material-parameter-reference.md +216 -0
- package/skills/ue-materials-rendering/references/post-process-settings.md +383 -0
- package/skills/ue-module-build-system/SKILL.md +487 -0
- package/skills/ue-module-build-system/references/build-cs-reference.md +418 -0
- package/skills/ue-module-build-system/references/common-build-errors.md +381 -0
- package/skills/ue-naming-conventions/SKILL.md +142 -0
- package/skills/ue-naming-conventions/references/asset-name-modifiers.md +233 -0
- package/skills/ue-naming-conventions/references/content-structure.md +83 -0
- package/skills/ue-networking-replication/SKILL.md +452 -0
- package/skills/ue-networking-replication/references/replication-patterns.md +526 -0
- package/skills/ue-networking-replication/references/rpc-decision-guide.md +393 -0
- package/skills/ue-niagara-effects/SKILL.md +531 -0
- package/skills/ue-niagara-effects/references/niagara-data-interfaces.md +491 -0
- package/skills/ue-niagara-effects/references/niagara-parameter-types.md +153 -0
- package/skills/ue-niagara-effects/references/niagara-workflow.md +115 -0
- package/skills/ue-packaging/SKILL.md +116 -0
- package/skills/ue-physics-collision/SKILL.md +423 -0
- package/skills/ue-physics-collision/references/collision-channel-setup.md +218 -0
- package/skills/ue-physics-collision/references/trace-patterns.md +499 -0
- package/skills/ue-procedural-generation/SKILL.md +505 -0
- package/skills/ue-procedural-generation/references/pcg-node-reference.md +381 -0
- package/skills/ue-procedural-generation/references/procedural-mesh-patterns.md +940 -0
- package/skills/ue-project-context/SKILL.md +305 -0
- package/skills/ue-sequencer-cinematics/SKILL.md +506 -0
- package/skills/ue-sequencer-cinematics/references/sequencer-patterns.md +629 -0
- package/skills/ue-serialization-savegames/SKILL.md +524 -0
- package/skills/ue-serialization-savegames/references/save-system-architecture.md +705 -0
- package/skills/ue-state-trees/SKILL.md +489 -0
- package/skills/ue-state-trees/references/state-tree-mass-integration.md +184 -0
- package/skills/ue-state-trees/references/state-tree-patterns.md +321 -0
- package/skills/ue-testing-debugging/SKILL.md +520 -0
- package/skills/ue-testing-debugging/references/automation-test-patterns.md +387 -0
- package/skills/ue-testing-debugging/references/profiling-commands.md +357 -0
- package/skills/ue-ui-umg-slate/SKILL.md +431 -0
- package/skills/ue-ui-umg-slate/references/common-ui-setup.md +484 -0
- package/skills/ue-ui-umg-slate/references/widget-types.md +487 -0
- package/skills/ue-world-level-streaming/SKILL.md +518 -0
- package/skills/ue-world-level-streaming/references/streaming-patterns.md +414 -0
- package/templates/gamedev/unreal-project-context.md +107 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ue-async-threading
|
|
3
|
+
description: "Async operations, threading, parallel execution, tasks, FRunnable, AsyncTask"
|
|
4
|
+
category: "gamedev"
|
|
5
|
+
argument-hint: "[operation o task]"
|
|
6
|
+
user-invocable: true
|
|
7
|
+
allowed-tools: Read, Glob, Grep, Bash, Write, Edit
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# UE Async and Threading
|
|
11
|
+
|
|
12
|
+
You are an expert in Unreal Engine's threading model, async task systems, and concurrent programming patterns.
|
|
13
|
+
|
|
14
|
+
Ask before writing: this skill edits async and threaded code paths. Propose the change and get approval before using Write or Edit.
|
|
15
|
+
|
|
16
|
+
## Context Check
|
|
17
|
+
|
|
18
|
+
Read `docs/unreal/project-context.md` before proceeding. Engine version matters: `UE::Tasks::Launch` is the modern preferred API (UE 5.0+), while `FAsyncTask` and TaskGraph remain fully supported. Determine: What work needs to be offloaded? Is UObject access required? What latency/throughput tradeoff is acceptable?
|
|
19
|
+
|
|
20
|
+
## Information Gathering
|
|
21
|
+
|
|
22
|
+
Ask the user if unclear:
|
|
23
|
+
- **Offload type** — CPU-bound computation, I/O wait, or periodic background work?
|
|
24
|
+
- **UObject interaction** — Does the background work need to read/write UObject state?
|
|
25
|
+
- **Lifetime** — One-shot task, recurring work, or long-lived thread?
|
|
26
|
+
- **Result delivery** — Fire-and-forget, or does the game thread need results back?
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## UE Threading Model
|
|
31
|
+
|
|
32
|
+
UE runs several named threads plus a scalable worker pool. Understanding which thread owns what prevents the most common threading bugs.
|
|
33
|
+
|
|
34
|
+
**Named threads:**
|
|
35
|
+
- **Game Thread** — All UObject access, Blueprint execution, gameplay logic. Check with `IsInGameThread()`.
|
|
36
|
+
- **Render Thread** — Render commands, scene proxy updates. `IsInRenderingThread()`.
|
|
37
|
+
- **RHI Thread** — GPU command submission (platform-dependent).
|
|
38
|
+
- **Worker Threads** — Unnamed pool threads for task dispatch. Count scales with CPU cores.
|
|
39
|
+
|
|
40
|
+
**The golden rule:** UObjects are game-thread-only. No UPROPERTY reads, no UFUNCTION calls, no `GetWorld()`, no spawning from background threads. Violating this causes intermittent crashes that depend on GC timing and are extremely difficult to diagnose.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Pattern Selection Guide
|
|
45
|
+
|
|
46
|
+
Choose the simplest API that fits your needs.
|
|
47
|
+
|
|
48
|
+
| Pattern | Best For | Lifetime | Result? |
|
|
49
|
+
|---------|----------|----------|---------|
|
|
50
|
+
| `AsyncTask(GameThread, Lambda)` | Dispatch to game thread from background | One-shot | No |
|
|
51
|
+
| `UE::Tasks::Launch` | General async work (preferred, UE5+) | One-shot | `TTask<T>` |
|
|
52
|
+
| `Async(EAsyncExecution, Lambda)` | Flexible dispatch with `TFuture` | One-shot | `TFuture<T>` |
|
|
53
|
+
| `FAsyncTask<T>` | Reusable pooled work units | Reusable | Via `GetTask()` |
|
|
54
|
+
| `FAutoDeleteAsyncTask<T>` | Fire-and-forget pooled work | One-shot | No |
|
|
55
|
+
| `TGraphTask<T>` | Complex dependency graphs | One-shot | `FGraphEvent` |
|
|
56
|
+
| `ParallelFor` | Data-parallel loops | Blocking | No |
|
|
57
|
+
| `FRunnable` + `FRunnableThread` | Long-lived dedicated threads | Persistent | Manual |
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## FRunnable and FRunnableThread
|
|
62
|
+
|
|
63
|
+
Use `FRunnable` only when you need a **dedicated, long-lived thread** -- a socket listener, a file watcher, or a continuous processing loop. For one-shot work, prefer `UE::Tasks::Launch` or `FAsyncTask`.
|
|
64
|
+
|
|
65
|
+
**Lifecycle:** `Init()` (new thread) -> `Run()` (new thread) -> `Exit()` (new thread, after Run returns). `Stop()` is called externally to request shutdown.
|
|
66
|
+
|
|
67
|
+
**FRunnableThread::Create** signature: `static FRunnableThread* Create(FRunnable*, const TCHAR* ThreadName, uint32 StackSize = 0, EThreadPriority = TPri_Normal, uint64 AffinityMask, EThreadCreateFlags)`.
|
|
68
|
+
|
|
69
|
+
**Key points:** `Stop()` signals the thread -- it does not block. `Kill(true)` calls `Stop()` then waits for completion. Always `delete` the `FRunnableThread*` after `Kill`. Use `std::atomic<bool> bShouldStop` in `Run()` loop, set it in `Stop()`.
|
|
70
|
+
|
|
71
|
+
See `references/threading-patterns.md` for a complete `FRunnable` subclass template with proper shutdown.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## FAsyncTask and FAutoDeleteAsyncTask
|
|
76
|
+
|
|
77
|
+
For **reusable work units** on the engine thread pool (`GThreadPool`). Subclass `FNonAbandonableTask` and implement `DoWork()` + `GetStatId()`.
|
|
78
|
+
|
|
79
|
+
```cpp
|
|
80
|
+
class FMyComputeTask : public FNonAbandonableTask
|
|
81
|
+
{
|
|
82
|
+
friend class FAsyncTask<FMyComputeTask>;
|
|
83
|
+
int32 Result = 0;
|
|
84
|
+
TArray<int32> InputData;
|
|
85
|
+
|
|
86
|
+
FMyComputeTask(TArray<int32> InData) : InputData(MoveTemp(InData)) {}
|
|
87
|
+
|
|
88
|
+
void DoWork()
|
|
89
|
+
{
|
|
90
|
+
for (int32 Val : InputData) { Result += Val; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
FORCEINLINE TStatId GetStatId() const
|
|
94
|
+
{
|
|
95
|
+
RETURN_QUICK_DECLARE_CYCLE_STAT(FMyComputeTask, STATGROUP_ThreadPoolAsyncTasks);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Usage:**
|
|
101
|
+
|
|
102
|
+
```cpp
|
|
103
|
+
// Reusable — you manage lifetime
|
|
104
|
+
auto* Task = new FAsyncTask<FMyComputeTask>(MoveTemp(Data));
|
|
105
|
+
Task->StartBackgroundTask(); // dispatches to GThreadPool
|
|
106
|
+
Task->EnsureCompletion(); // blocks or runs inline if not started
|
|
107
|
+
int32 R = Task->GetTask().Result;
|
|
108
|
+
delete Task;
|
|
109
|
+
|
|
110
|
+
// Fire-and-forget — auto-deletes on completion
|
|
111
|
+
(new FAutoDeleteAsyncTask<FMyComputeTask>(MoveTemp(Data)))->StartBackgroundTask();
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`IsWorkDone()` is the non-blocking completion check. `Cancel()` prevents execution if not yet started. `StartSynchronousTask()` runs inline on the calling thread.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## TaskGraph
|
|
119
|
+
|
|
120
|
+
For work with **complex dependency chains**. Each task declares prerequisites; the scheduler handles ordering.
|
|
121
|
+
|
|
122
|
+
```cpp
|
|
123
|
+
class FMyGraphTask
|
|
124
|
+
{
|
|
125
|
+
public:
|
|
126
|
+
FMyGraphTask(int32 InValue) : Value(InValue) {}
|
|
127
|
+
|
|
128
|
+
static ESubsequentsMode::Type GetSubsequentsMode()
|
|
129
|
+
{ return ESubsequentsMode::TrackSubsequents; }
|
|
130
|
+
|
|
131
|
+
ENamedThreads::Type GetDesiredThread()
|
|
132
|
+
{ return ENamedThreads::AnyThread; }
|
|
133
|
+
|
|
134
|
+
TStatId GetStatId() const
|
|
135
|
+
{ RETURN_QUICK_DECLARE_CYCLE_STAT(FMyGraphTask, STATGROUP_TaskGraphTasks); }
|
|
136
|
+
|
|
137
|
+
void DoTask(ENamedThreads::Type CurrentThread, const FGraphEventRef& MyCompletionGraphEvent)
|
|
138
|
+
{ /* work here */ }
|
|
139
|
+
|
|
140
|
+
private:
|
|
141
|
+
int32 Value;
|
|
142
|
+
};
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Dispatching with prerequisites:**
|
|
146
|
+
|
|
147
|
+
```cpp
|
|
148
|
+
FGraphEventArray Prerequisites; // TArray<FGraphEventRef, TInlineAllocator<4>>
|
|
149
|
+
Prerequisites.Add(SomePriorEvent);
|
|
150
|
+
|
|
151
|
+
FGraphEventRef TaskEvent = TGraphTask<FMyGraphTask>::CreateTask(&Prerequisites)
|
|
152
|
+
.ConstructAndDispatchWhenReady(42); // args forwarded to constructor
|
|
153
|
+
|
|
154
|
+
FTaskGraphInterface::Get().WaitUntilTaskCompletes(TaskEvent, ENamedThreads::GameThread);
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**Quick dispatch** (no custom class needed):
|
|
158
|
+
|
|
159
|
+
```cpp
|
|
160
|
+
AsyncTask(ENamedThreads::GameThread, [this]()
|
|
161
|
+
{
|
|
162
|
+
MyActor->UpdateHealth(NewValue); // safe — runs on game thread
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## UE::Tasks::Launch (Modern Preferred API)
|
|
169
|
+
|
|
170
|
+
Recommended for new code (UE 5.0+). Simpler syntax than TaskGraph, automatic thread pool dispatch, built-in chaining.
|
|
171
|
+
|
|
172
|
+
```cpp
|
|
173
|
+
#include "Tasks/Task.h"
|
|
174
|
+
|
|
175
|
+
UE::Tasks::TTask<int32> Task = UE::Tasks::Launch(
|
|
176
|
+
UE_SOURCE_LOCATION,
|
|
177
|
+
[]() { return ExpensiveComputation(); }
|
|
178
|
+
);
|
|
179
|
+
int32 Result = Task.GetResult(); // blocks until complete
|
|
180
|
+
|
|
181
|
+
// With prerequisites
|
|
182
|
+
UE::Tasks::TTask<FVector> TaskA = UE::Tasks::Launch(UE_SOURCE_LOCATION,
|
|
183
|
+
[]() { return ComputePosition(); });
|
|
184
|
+
|
|
185
|
+
UE::Tasks::TTask<void> TaskB = UE::Tasks::Launch(UE_SOURCE_LOCATION,
|
|
186
|
+
[&TaskA]() { ProcessPosition(TaskA.GetResult()); },
|
|
187
|
+
UE::Tasks::Prerequisites(TaskA)
|
|
188
|
+
);
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
**TTask<T> API:** `GetResult()` blocks and returns result. `IsCompleted()` non-blocking. `Wait()` / `Wait(FTimespan)` for timed blocking. `TryRetractAndExecute()` runs inline if not yet started (work stealing).
|
|
192
|
+
|
|
193
|
+
**FTaskEvent** for manual synchronization -- call `Trigger()` to unblock dependent tasks.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## Async, TFuture, and TPromise
|
|
198
|
+
|
|
199
|
+
`Async()` is the most flexible one-shot dispatch. Returns `TFuture<T>` with execution context control.
|
|
200
|
+
|
|
201
|
+
```cpp
|
|
202
|
+
TFuture<FMyResult> Future = Async(EAsyncExecution::ThreadPool,
|
|
203
|
+
[]() -> FMyResult { return ComputeResult(); },
|
|
204
|
+
[]() { /* completion callback — runs on unspecified thread */ }
|
|
205
|
+
);
|
|
206
|
+
FMyResult R = Future.Get(); // blocks, does NOT invalidate (unlike std::future)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**EAsyncExecution modes:**
|
|
210
|
+
|
|
211
|
+
| Mode | Thread |
|
|
212
|
+
|------|--------|
|
|
213
|
+
| `TaskGraph` | Worker via TaskGraph |
|
|
214
|
+
| `TaskGraphMainThread` | Game thread via TaskGraph |
|
|
215
|
+
| `Thread` | New dedicated thread |
|
|
216
|
+
| `ThreadPool` | `GThreadPool` worker |
|
|
217
|
+
| `LargeThreadPool` | `GLargeThreadPool` (WITH_EDITOR only) |
|
|
218
|
+
|
|
219
|
+
**Convenience:** `AsyncPool(GThreadPool, Lambda)`, `AsyncThread(Lambda, StackSize, Priority)`.
|
|
220
|
+
|
|
221
|
+
### TFuture<T> API
|
|
222
|
+
|
|
223
|
+
Key difference from `std::future`: `Get()` does **not** invalidate. Call it multiple times safely. `Consume()` invalidates like `std::future::get()`.
|
|
224
|
+
|
|
225
|
+
- `IsReady()` -- non-blocking check
|
|
226
|
+
- `Wait()` / `WaitFor(FTimespan)` -- block without consuming
|
|
227
|
+
- `Then(Continuation)` / `Next(Continuation)` -- chaining, continuation runs on any thread
|
|
228
|
+
- `Share()` -- convert to shared future
|
|
229
|
+
|
|
230
|
+
### TPromise<T>
|
|
231
|
+
|
|
232
|
+
For producer-consumer patterns where producing and consuming sides are decoupled.
|
|
233
|
+
|
|
234
|
+
```cpp
|
|
235
|
+
TPromise<FMyData> Promise;
|
|
236
|
+
TFuture<FMyData> Future = Promise.GetFuture(); // call once
|
|
237
|
+
|
|
238
|
+
Async(EAsyncExecution::ThreadPool, [P = MoveTemp(Promise)]() mutable
|
|
239
|
+
{
|
|
240
|
+
P.SetValue(GenerateData()); // or EmplaceValue()
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
FMyData Result = Future.Get(); // blocks on game thread
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## ParallelFor
|
|
249
|
+
|
|
250
|
+
For **data-parallel loops** where each iteration is independent. The calling thread participates -- `ParallelFor` blocks until all iterations complete.
|
|
251
|
+
|
|
252
|
+
```cpp
|
|
253
|
+
ParallelFor(Meshes.Num(), [&Meshes](int32 Index)
|
|
254
|
+
{
|
|
255
|
+
ProcessMesh(Meshes[Index]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// With MinBatchSize — prevents overhead for small workloads
|
|
259
|
+
ParallelFor(TEXT("ProcessMeshes"), Meshes.Num(), 64,
|
|
260
|
+
[&Meshes](int32 Index) { ProcessMesh(Meshes[Index]); }
|
|
261
|
+
);
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**EParallelForFlags:**
|
|
265
|
+
|
|
266
|
+
| Flag | Value | Effect |
|
|
267
|
+
|------|-------|--------|
|
|
268
|
+
| `None` | 0 | Default behavior |
|
|
269
|
+
| `ForceSingleThread` | 1 | Debug: run sequentially |
|
|
270
|
+
| `Unbalanced` | 2 | Iterations have variable cost |
|
|
271
|
+
| `PumpRenderingThread` | 4 | Pump render commands while waiting |
|
|
272
|
+
| `BackgroundPriority` | 8 | Lower priority for workers |
|
|
273
|
+
|
|
274
|
+
`ParallelForWithTaskContext` provides a per-worker context object -- use when workers need scratch memory to avoid per-iteration allocation.
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## Game Thread Safety
|
|
279
|
+
|
|
280
|
+
Threading bugs in UE are **silent** -- they corrupt state, cause GC races, and produce bugs that only reproduce under load. See `references/thread-safety-guide.md` for complete patterns.
|
|
281
|
+
|
|
282
|
+
### UObject Access Rules
|
|
283
|
+
|
|
284
|
+
1. **All UObject access must happen on the game thread.** No reads, writes, or function calls from background threads.
|
|
285
|
+
2. **GC can destroy UObjects between ticks.** A raw `UObject*` captured in a lambda may be dangling by execution time.
|
|
286
|
+
3. **Spawning, destroying, modifying components** -- game thread only.
|
|
287
|
+
|
|
288
|
+
### Safe Dispatch Pattern
|
|
289
|
+
|
|
290
|
+
```cpp
|
|
291
|
+
AsyncTask(ENamedThreads::GameThread, [WeakActor = TWeakObjectPtr<AActor>(MyActor)]()
|
|
292
|
+
{
|
|
293
|
+
if (AActor* Actor = WeakActor.Get()) // nullptr if GC'd
|
|
294
|
+
{
|
|
295
|
+
Actor->UpdateFromBackgroundWork(NewData);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
**Always capture `TWeakObjectPtr`**, never raw `UObject*`. For non-UObject shared data, use `TSharedPtr<T, ESPMode::ThreadSafe>` -- the default `ESPMode::NotThreadSafe` has non-atomic refcounting.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## Synchronization Primitives
|
|
305
|
+
|
|
306
|
+
### FCriticalSection (Recursive Mutex)
|
|
307
|
+
|
|
308
|
+
`FCriticalSection` is `UE::FPlatformRecursiveMutex`. Same thread can lock multiple times without deadlocking.
|
|
309
|
+
|
|
310
|
+
```cpp
|
|
311
|
+
FCriticalSection DataLock;
|
|
312
|
+
void AddPosition(const FVector& Pos)
|
|
313
|
+
{
|
|
314
|
+
FScopeLock Lock(&DataLock); // RAII — unlocks on scope exit
|
|
315
|
+
SharedPositions.Add(Pos);
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### FRWLock (Read-Write Lock)
|
|
320
|
+
|
|
321
|
+
Multiple readers OR one exclusive writer. `FRWLock` is **not** recursive -- do not nest.
|
|
322
|
+
|
|
323
|
+
```cpp
|
|
324
|
+
FRWLock CacheLock;
|
|
325
|
+
FVector Read(FName Key) { FReadScopeLock RL(CacheLock); return Cache.FindRef(Key); }
|
|
326
|
+
void Write(FName K, FVector V) { FWriteScopeLock WL(CacheLock); Cache.Add(K, V); }
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### FEventRef and Atomics
|
|
330
|
+
|
|
331
|
+
`FEventRef` is the RAII wrapper for thread signaling. Prefer over raw `FEvent*`.
|
|
332
|
+
|
|
333
|
+
```cpp
|
|
334
|
+
FEventRef WorkReady(EEventMode::AutoReset);
|
|
335
|
+
WorkReady->Trigger(); // producer signals
|
|
336
|
+
WorkReady->Wait(); // consumer blocks
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
`FThreadSafeCounter` and `FThreadSafeBool` are deprecated -- use `std::atomic<int32>` and `std::atomic<bool>` directly.
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## TQueue (Lock-Free Queue)
|
|
344
|
+
|
|
345
|
+
Thread-safe queue for producer-consumer without locks.
|
|
346
|
+
|
|
347
|
+
```cpp
|
|
348
|
+
TQueue<FMyMessage, EQueueMode::Mpsc> MessageQueue;
|
|
349
|
+
|
|
350
|
+
// Producer (any thread)
|
|
351
|
+
MessageQueue.Enqueue(FMyMessage{...});
|
|
352
|
+
|
|
353
|
+
// Consumer (game thread tick)
|
|
354
|
+
FMyMessage Msg;
|
|
355
|
+
while (MessageQueue.Dequeue(Msg)) { ProcessMessage(Msg); }
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
`Spsc` -- single-producer, single-consumer (slightly faster). `Mpsc` -- multiple-producer, single-consumer (most common). `Peek()` reads without dequeuing.
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## Common Mistakes
|
|
363
|
+
|
|
364
|
+
**Accessing UObject from background thread -- dispatch results back:**
|
|
365
|
+
```cpp
|
|
366
|
+
// WRONG — UObject access off game thread
|
|
367
|
+
Async(EAsyncExecution::ThreadPool, [this]()
|
|
368
|
+
{ MyActor->Health = ComputeNewHealth(); });
|
|
369
|
+
|
|
370
|
+
// RIGHT — compute off-thread, apply on game thread via weak pointer
|
|
371
|
+
Async(EAsyncExecution::ThreadPool, [WeakActor = TWeakObjectPtr<AActor>(MyActor)]()
|
|
372
|
+
{
|
|
373
|
+
float NewHealth = ComputeNewHealth();
|
|
374
|
+
AsyncTask(ENamedThreads::GameThread, [WeakActor, NewHealth]()
|
|
375
|
+
{ if (AActor* A = WeakActor.Get()) { A->SetHealth(NewHealth); } });
|
|
376
|
+
});
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
**TSharedPtr with default ESPMode across threads:**
|
|
380
|
+
```cpp
|
|
381
|
+
// WRONG — non-atomic refcount
|
|
382
|
+
auto Data = MakeShared<FMyData>();
|
|
383
|
+
// RIGHT
|
|
384
|
+
auto Data = MakeShared<FMyData, ESPMode::ThreadSafe>();
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
**FRWLock nested acquisition -- deadlock:**
|
|
388
|
+
```cpp
|
|
389
|
+
// WRONG — FRWLock is NOT recursive
|
|
390
|
+
FReadScopeLock Outer(Lock);
|
|
391
|
+
FReadScopeLock Inner(Lock); // DEADLOCK on some platforms
|
|
392
|
+
// RIGHT — acquire once, do all reads, release
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
**ParallelFor with shared mutable state:**
|
|
396
|
+
```cpp
|
|
397
|
+
// WRONG — concurrent writes
|
|
398
|
+
int32 Total = 0;
|
|
399
|
+
ParallelFor(Data.Num(), [&](int32 i) { Total += Data[i]; });
|
|
400
|
+
// RIGHT — atomic accumulation
|
|
401
|
+
std::atomic<int32> Total{0};
|
|
402
|
+
ParallelFor(Data.Num(), [&](int32 i)
|
|
403
|
+
{ Total.fetch_add(Data[i], std::memory_order_relaxed); });
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
---
|
|
407
|
+
|
|
408
|
+
## Related Skills
|
|
409
|
+
|
|
410
|
+
- `/ue-cpp-foundations` -- TSharedPtr, TWeakObjectPtr, GC lifetime, smart pointer rules
|
|
411
|
+
- `/ue-gameplay-framework` -- game thread tick flow, actor lifecycle ordering
|
|
412
|
+
- `/ue-networking-replication` -- RPC dispatch threads, replication callbacks
|
|
413
|
+
- `/ue-data-assets-tables` -- FStreamableManager async loading, soft references
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
## Checklist
|
|
418
|
+
|
|
419
|
+
Validate before considering the work done:
|
|
420
|
+
|
|
421
|
+
- [ ] UObject access happens on the game thread only
|
|
422
|
+
- [ ] Every async completion path handles the owner being destroyed mid-flight
|
|
423
|
+
- [ ] Shared state is protected, or ownership is transferred instead of shared
|
|
424
|
+
- [ ] Conventions in `docs/unreal/project-context.md` were read and followed
|
|
425
|
+
- [ ] The Common Mistakes section of this skill was reviewed against the change
|
|
426
|
+
|
|
427
|
+
**PASS** — every item holds. **CONCERNS** — the work stands but at least one item is unresolved and worth flagging to the user. **FAIL** — an item is violated in a way that breaks correctness, replication or the frame budget; fix it before handing the work over.
|
|
428
|
+
|
|
429
|
+
## Next Steps
|
|
430
|
+
|
|
431
|
+
- `/perf-profile` — confirm the work actually moved off the critical path
|
|
432
|
+
- `/code-review` — architectural and quality review of the change
|
|
433
|
+
- `/commit` — conventional commit once the change is verified
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Thread Safety Guide
|
|
2
|
+
|
|
3
|
+
Patterns and rules for safe cross-thread data access in Unreal Engine.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## UObject Access Rules
|
|
8
|
+
|
|
9
|
+
UObjects are managed by the garbage collector, which runs on the game thread. Any access from another thread risks reading a dangling pointer or corrupting GC bookkeeping.
|
|
10
|
+
|
|
11
|
+
**What counts as "access":** Reading any UPROPERTY, calling any UFUNCTION, calling `GetWorld()`, `GetOwner()`, `GetComponentByClass()`, iterating component arrays, spawning or destroying actors, modifying transform -- all of these are game-thread-only operations.
|
|
12
|
+
|
|
13
|
+
**Why it seems to work sometimes:** GC only runs at specific points in the frame. A background thread that finishes quickly may never collide with GC. But under load, longer GC pauses, or different hardware timing, the race condition triggers -- producing crashes that are nearly impossible to reproduce in development.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## TWeakObjectPtr for Cross-Thread UObject References
|
|
18
|
+
|
|
19
|
+
Never capture raw `UObject*` in lambdas dispatched to other threads or deferred to future game-thread ticks. The object may be garbage collected before execution.
|
|
20
|
+
|
|
21
|
+
```cpp
|
|
22
|
+
// Capture weak pointer BEFORE dispatching
|
|
23
|
+
TWeakObjectPtr<AMyActor> WeakActor(MyActor);
|
|
24
|
+
|
|
25
|
+
UE::Tasks::Launch(UE_SOURCE_LOCATION, [WeakActor]()
|
|
26
|
+
{
|
|
27
|
+
float Result = ExpensiveCompute();
|
|
28
|
+
|
|
29
|
+
// Dispatch result back to game thread with safety check
|
|
30
|
+
AsyncTask(ENamedThreads::GameThread, [WeakActor, Result]()
|
|
31
|
+
{
|
|
32
|
+
if (AMyActor* Actor = WeakActor.Get())
|
|
33
|
+
{
|
|
34
|
+
Actor->ApplyComputedResult(Result);
|
|
35
|
+
}
|
|
36
|
+
// If Get() returns nullptr, actor was GC'd — silently skip
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Rule:** Create the `TWeakObjectPtr` on the game thread, then copy it into lambdas. `TWeakObjectPtr::Get()` is safe to call from the game thread to check validity.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## TSharedPtr Thread Safety Modes
|
|
46
|
+
|
|
47
|
+
`TSharedPtr<T>` defaults to `ESPMode::NotThreadSafe` -- the reference count uses non-atomic operations. Sharing across threads causes data races on the refcount itself.
|
|
48
|
+
|
|
49
|
+
```cpp
|
|
50
|
+
// WRONG — default ESPMode is not thread-safe
|
|
51
|
+
TSharedPtr<FComputeBuffer> Buffer = MakeShared<FComputeBuffer>();
|
|
52
|
+
|
|
53
|
+
// RIGHT — atomic refcount for cross-thread sharing
|
|
54
|
+
TSharedPtr<FComputeBuffer, ESPMode::ThreadSafe> Buffer =
|
|
55
|
+
MakeShared<FComputeBuffer, ESPMode::ThreadSafe>();
|
|
56
|
+
|
|
57
|
+
// The data INSIDE the buffer still needs its own synchronization
|
|
58
|
+
// ESPMode::ThreadSafe only protects the pointer/refcount operations
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Key distinction:** `ESPMode::ThreadSafe` makes the *pointer operations* (copy, destroy, reset) thread-safe. It does NOT protect the pointed-to data. You still need locks or atomics for the actual data if mutated concurrently.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## FCriticalSection + FScopeLock Pattern
|
|
66
|
+
|
|
67
|
+
Use `FCriticalSection` (recursive mutex) when multiple threads read and write shared non-UObject data.
|
|
68
|
+
|
|
69
|
+
```cpp
|
|
70
|
+
class FThreadSafeAccumulator
|
|
71
|
+
{
|
|
72
|
+
public:
|
|
73
|
+
void AddSample(float Value)
|
|
74
|
+
{
|
|
75
|
+
FScopeLock Lock(&CriticalSection); // acquires on construction
|
|
76
|
+
Samples.Add(Value);
|
|
77
|
+
Total += Value;
|
|
78
|
+
} // releases when Lock goes out of scope
|
|
79
|
+
|
|
80
|
+
float GetAverage() const
|
|
81
|
+
{
|
|
82
|
+
FScopeLock Lock(&CriticalSection);
|
|
83
|
+
return Samples.Num() > 0 ? Total / Samples.Num() : 0.0f;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private:
|
|
87
|
+
mutable FCriticalSection CriticalSection;
|
|
88
|
+
TArray<float> Samples;
|
|
89
|
+
float Total = 0.0f;
|
|
90
|
+
};
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Always use `FScopeLock`** -- never call `Lock()` / `Unlock()` manually. Manual lock/unlock risks missing the unlock path on early returns or exceptions.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## FRWLock for Read-Heavy Scenarios
|
|
98
|
+
|
|
99
|
+
When reads vastly outnumber writes, `FRWLock` allows concurrent readers while writers get exclusive access. Significant performance win over `FCriticalSection` in read-heavy workloads.
|
|
100
|
+
|
|
101
|
+
```cpp
|
|
102
|
+
class FThreadSafeCache
|
|
103
|
+
{
|
|
104
|
+
public:
|
|
105
|
+
FVector LookupPosition(FName Key) const
|
|
106
|
+
{
|
|
107
|
+
FReadScopeLock ReadLock(RWLock); // multiple readers allowed
|
|
108
|
+
const FVector* Found = PositionCache.Find(Key);
|
|
109
|
+
return Found ? *Found : FVector::ZeroVector;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
void UpdatePosition(FName Key, FVector NewPos)
|
|
113
|
+
{
|
|
114
|
+
FWriteScopeLock WriteLock(RWLock); // exclusive access
|
|
115
|
+
PositionCache.Add(Key, NewPos);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private:
|
|
119
|
+
mutable FRWLock RWLock;
|
|
120
|
+
TMap<FName, FVector> PositionCache;
|
|
121
|
+
};
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Critical:** `FRWLock` is NOT recursive. Do not acquire a read lock while holding a read lock, and do not acquire a write lock while holding a read lock. Both cause deadlocks on some platforms.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## std::atomic Patterns
|
|
129
|
+
|
|
130
|
+
`FThreadSafeCounter` and `FThreadSafeBool` are deprecated in engine source comments. Use `std::atomic` directly.
|
|
131
|
+
|
|
132
|
+
```cpp
|
|
133
|
+
class FBackgroundProcessor
|
|
134
|
+
{
|
|
135
|
+
public:
|
|
136
|
+
void RequestStop() { bShouldStop.store(true, std::memory_order_relaxed); }
|
|
137
|
+
bool IsStopped() const { return bShouldStop.load(std::memory_order_relaxed); }
|
|
138
|
+
|
|
139
|
+
int32 GetProcessedCount() const { return ProcessedCount.load(std::memory_order_relaxed); }
|
|
140
|
+
void IncrementProcessed() { ProcessedCount.fetch_add(1, std::memory_order_relaxed); }
|
|
141
|
+
|
|
142
|
+
private:
|
|
143
|
+
std::atomic<bool> bShouldStop{false};
|
|
144
|
+
std::atomic<int32> ProcessedCount{0};
|
|
145
|
+
};
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**Memory ordering:** `std::memory_order_relaxed` is sufficient for simple flags and counters where you do not need happens-before guarantees with other data. Use `std::memory_order_acquire` / `std::memory_order_release` when the atomic guards visibility of other non-atomic writes.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Double-Buffering Pattern
|
|
153
|
+
|
|
154
|
+
For transferring bulk data from a background thread to the game thread without locks during the hot path. The background thread writes to one buffer while the game thread reads from the other.
|
|
155
|
+
|
|
156
|
+
```cpp
|
|
157
|
+
class FDoubleBufferedData
|
|
158
|
+
{
|
|
159
|
+
public:
|
|
160
|
+
// Background thread writes to back buffer
|
|
161
|
+
void WriteBackBuffer(TArray<FVector>&& NewData)
|
|
162
|
+
{
|
|
163
|
+
FScopeLock Lock(&SwapLock);
|
|
164
|
+
BackBuffer = MoveTemp(NewData);
|
|
165
|
+
bSwapPending.store(true, std::memory_order_release);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Game thread swaps and reads — call once per tick
|
|
169
|
+
const TArray<FVector>& SwapAndRead()
|
|
170
|
+
{
|
|
171
|
+
if (bSwapPending.load(std::memory_order_acquire))
|
|
172
|
+
{
|
|
173
|
+
FScopeLock Lock(&SwapLock);
|
|
174
|
+
Swap(FrontBuffer, BackBuffer);
|
|
175
|
+
bSwapPending.store(false, std::memory_order_relaxed);
|
|
176
|
+
}
|
|
177
|
+
return FrontBuffer; // lock-free read on game thread
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private:
|
|
181
|
+
TArray<FVector> FrontBuffer; // game thread reads
|
|
182
|
+
TArray<FVector> BackBuffer; // background thread writes
|
|
183
|
+
FCriticalSection SwapLock; // only held during swap
|
|
184
|
+
std::atomic<bool> bSwapPending{false};
|
|
185
|
+
};
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The lock is only contended during the brief swap operation. The game thread reads the front buffer without any synchronization for the rest of the frame.
|
|
189
|
+
|
|
190
|
+
---
|
|
191
|
+
|
|
192
|
+
## Lock Ordering Rules
|
|
193
|
+
|
|
194
|
+
When multiple locks must be held simultaneously, always acquire them in a consistent global order. Inconsistent ordering between threads causes deadlocks.
|
|
195
|
+
|
|
196
|
+
**Rules:**
|
|
197
|
+
1. **Define a total order** for all locks in your system (e.g., by subsystem, then by allocation order).
|
|
198
|
+
2. **Always acquire in ascending order.** If thread A holds Lock1 and needs Lock2, Lock2 must have a higher order than Lock1.
|
|
199
|
+
3. **Never hold a lock while calling unknown code** (callbacks, delegates, virtual functions). The callee may try to acquire a lock with a lower order.
|
|
200
|
+
4. **Prefer lock-free patterns** (`TQueue`, `std::atomic`, double-buffering) when they fit. No locks means no deadlocks.
|
|
201
|
+
5. **Keep critical sections short.** Do expensive computation outside the lock, then lock briefly to commit results.
|
|
202
|
+
|
|
203
|
+
```cpp
|
|
204
|
+
// WRONG — inconsistent order causes deadlock
|
|
205
|
+
// Thread 1: Lock(A) -> Lock(B)
|
|
206
|
+
// Thread 2: Lock(B) -> Lock(A)
|
|
207
|
+
|
|
208
|
+
// RIGHT — consistent order
|
|
209
|
+
// Thread 1: Lock(A) -> Lock(B)
|
|
210
|
+
// Thread 2: Lock(A) -> Lock(B)
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
When in doubt, restructure to use a single lock or a lock-free queue. Two locks that must be held simultaneously usually indicate a design that can be simplified.
|