Skip to content

Commit 275f0cb

Browse files
committed
add async docs, update migrate-v5 and web requests docs to reference it
1 parent 38b65fe commit 275f0cb

3 files changed

Lines changed: 305 additions & 77 deletions

File tree

tutorials/async.md

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
# Async
2+
3+
Since Geode v5.0.0, the task system has been replaced by a true **asynchronous** system, based on the [Arc](https://github.com/dankmeme01/arc) library. Async has many advantages over tasks or regular multithreading:
4+
* Lightweight -> unlike threads, async tasks are cheap to construct; you can spawn **thousands** with relatively little resources
5+
* Cooperative -> instead of your OS choosing to forcibly pause threads, async tasks **cooperatively** suspend and let another task run
6+
* Less boilerplate
7+
8+
Async is **perfect** for any workload that consists of waiting - such as making a request to a server, waiting for a notification from another thread, waiting a few seconds between tasks, etc.
9+
10+
Geode provides some async utilities in the `<Geode/utils/async.hpp>` header, as well a global async runtime used by mods. You are not forced to use it, but there's rarely a good reason not to.
11+
12+
## Coroutines / Futures / Pollables
13+
14+
Simply put, a **coroutine** is a function that can pause its execution and resume later. Virtually always, coroutines here will have the return type `arc::Future<T>`, and they are defined by the presence of `co_return`, `co_await` or `co_yield` inside their body. Here are some examples of coroutines:
15+
16+
```cpp
17+
arc::Future<> coro1() {
18+
co_return;
19+
}
20+
21+
arc::Future<int> coro2() {
22+
co_await arc::sleep(asp::Duration::fromMillis(100));
23+
co_return 42;
24+
}
25+
26+
auto coro3 = [] -> arc::Future<> {
27+
co_return;
28+
};
29+
30+
// Note: the example below is not a coroutine, but it is Undefined Behavior
31+
// A coroutine must have at least one `co_xxx` statement inside, even without a return value.
32+
arc::Future<> nonCoro() {
33+
}
34+
35+
// This function is NOT a coroutine, but it's also entirely valid.
36+
// If you have a function that delegates work to another coroutine,
37+
// `return coro()` will have identical effect to `co_return co_await coro()`
38+
arc::Future<> coro4() {
39+
return coro1();
40+
}
41+
42+
// Though, this case would be invalid, because it is an error to mix
43+
// `return` and `co_await`/`co_return` in one function.
44+
arc::Future<> coro5() {
45+
int value = co_await coro2();
46+
47+
return coro1();
48+
}
49+
```
50+
51+
Coroutines are lazy, and don't run any code until **awaited**. To execute another coroutine (and optionally get its result), the `co_await` expression should be used (**only** possible within a coroutine):
52+
53+
```cpp
54+
arc::Future<int> coro1(int z) {
55+
log::debug("coro1 called");
56+
co_return z * 2;
57+
}
58+
59+
arc::Future<> coro() {
60+
arc::Future<int> fut = coro1(2);
61+
// no evaluation happens until we await it, so the log hasn't been printed yet
62+
63+
int value = co_await fut;
64+
// now the body was executed and the coroutine has returned
65+
}
66+
67+
int func() {
68+
// this will error, func() is not a coroutine
69+
co_await coro();
70+
}
71+
```
72+
73+
Most async functions you will write yourself are going to be coroutines that return `Future<>`. Arc also has a lower-level concept called **Pollable**, but it's not described here as you will likely not need to use it directly (but see Arc's readme if you need to)
74+
75+
## Tasks
76+
77+
An `arc::Task<>` is an independent unit of execution, similar to an `std::thread`. Unlike a `Future`, which needs to be awaited or polled by another future to make progress, tasks run **independently**. They are an **entry point** to executing async code, since it's impossible to run a future outside of a task.
78+
79+
Here's an example of two tasks, one sending notifications and the other receiving them:
80+
```cpp
81+
arc::Notify notify;
82+
83+
async::spawn([notify] -> arc::Future<> {
84+
while (true) {
85+
notify.notifyOne();
86+
co_await arc::sleep(asp::Duration::fromMillis(100));
87+
}
88+
});
89+
90+
async::spawn([notify] -> arc::Future<> {
91+
while (true) {
92+
co_await notify.notified();
93+
log::info("Received notification!");
94+
}
95+
});
96+
```
97+
98+
Here, both tasks will run independently forever and generate a notifiaction every 100ms. This is very useful for things like singleton worker threads, that wait for main thread to signal them about needing to do a certain task.
99+
100+
Tasks can also be awaited by other tasks and sync code, or aborted via the `TaskHandle`:
101+
```cpp
102+
auto handle = async::spawn([] -> arc::Future<int> {
103+
// yield() is like a tiny sleep
104+
co_await arc::yield();
105+
106+
co_return 42;
107+
});
108+
109+
// if we are inside a coroutine, we can await the task and get the output
110+
int value = co_await handle;
111+
112+
// if we are NOT inside a coroutine, we can block until it's done
113+
// *never* use this when inside a runtime, because that causes resource starvation
114+
int value = handle.blockOn();
115+
116+
// if we don't care about the task, we can cancel it, which will cleanup all resources owned by it
117+
handle.abort();
118+
```
119+
120+
While everything above is generic to Arc, Geode also provides some specific utils that are very handy in GD itself. The `geode::async::spawn` function has an overload that takes another function, which will be called on **main thread** once the task finishes, making it very similar to the old `Task::listen`:
121+
122+
```cpp
123+
// For example, here's a simple way to run some code exactly 1 second from now, using async
124+
async::spawn(
125+
arc::sleep(asp::Duration::fromSecs(1)),
126+
[] { log::info("one second has passed!"); }
127+
);
128+
129+
// Send a web request and handle response on main thread
130+
async::spawn(
131+
web::WebRequest().get("https://example.org"),
132+
[](web::WebResponse resp) {
133+
FLAlertLayer::create("Status", fmt::to_string(resp.code()), "OK")->show();
134+
}
135+
);
136+
```
137+
138+
As well as the `async::TaskHolder<T>` class, which lets you tie a task's lifetime to a specific object. This will automatically cancel the task by calling `abort()` on the handle, similar to the `EventListener` in v4.
139+
140+
```cpp
141+
async::TaskHolder<WebResponse> listener;
142+
143+
listener.spawn(
144+
web::WebRequest().get("https://example.org"),
145+
[](web::WebResponse value) {
146+
log::debug("Status: {}", resp.code());
147+
}
148+
);
149+
150+
// Calling spawn again will cancel the previous task and replace it:
151+
listener.spawn(...);
152+
153+
// Once the listener is destroyed, the task is automatically cancelled.
154+
// But you can always choose to do it manually:
155+
listener.cancel();
156+
```
157+
158+
Tasks can have names, and we recommend setting them to make async debugging easier:
159+
```cpp
160+
auto handle = async::spawn(...);
161+
handle.setName("My Web Task");
162+
163+
// TaskHolder also has a second way of setting the name, directly when spawning
164+
async::TaskHolder<void> listener;
165+
listener.spawn(
166+
"Useless Task",
167+
arc::yield(),
168+
[] {}
169+
);
170+
```
171+
172+
## Best practices
173+
174+
While async is a great and powerful tool, in C++ it's unfortunately easy to misuse it. This part of the page is dedicated for showing how to use async correctly, and what to avoid doing.
175+
176+
### Blocking
177+
178+
Blocking a thread of the async runtime is one of the worst things you can do, as it prevents all other tasks from running. Take this code for example:
179+
180+
```cpp
181+
async::spawn([] -> arc::Future {
182+
std::this_thread::sleep_for(std::chrono::seconds{1});
183+
});
184+
```
185+
186+
If you run this, you will likely get angry messages like this in your console:
187+
```
188+
[WARN] [Worker 1] task Task @ 0x7c4bc6fe0790 took 1.002s to yield
189+
```
190+
191+
This is because an async runtime has a fixed number of threads, which drive all tasks together. When you invoke an operation that waits *asynchronously*, for example `arc::sleep`, you tell the runtime "Hey, you can run other tasks for now, wake me up when I need to run", and it simply suspends your task. When you invoke a *blocking* operation, you bypass the runtime, steal its thread and don't give other tasks an opportunity to run.
192+
193+
Almost every blocking operation has a non-blocking counterpart in Arc:
194+
* Sleep -> `arc::sleep`
195+
* Locking a mutex -> Use `arc::Mutex` instead
196+
* Semaphores / condition variables -> Use `arc::Semaphore`, `arc::Notify` (`arc::mpsc` or `arc::oneshot` for message channels)
197+
* Networking -> Use `arc::UdpSocket`, `arc::TcpStream`, which are non-blocking
198+
199+
If you must run an operation that blocks and have no way to go around it, use the **blocking thread pool**. This is a built-in feature of Arc that allows you to run tasks that block or do a lot of heavy CPU work, without slowing down the runtime:
200+
201+
```cpp
202+
auto handle = async::runtime().spawnBlocking<uint64_t>([] {
203+
// simulate some expensive calculation
204+
uint64_t x = 1;
205+
for (int i = 0; i < 1024; i++) {
206+
x = x * (x + i);
207+
}
208+
return x;
209+
});
210+
211+
// The function above now runs in parallel, and cannot be stopped unlike a task
212+
// To get the output value, you have two ways:
213+
214+
// 1. await if inside an async task
215+
uint64_t value = co_await handle;
216+
// 2. block (only if NOT inside the async runtime!)
217+
uint64_t value = handle.blockOn();
218+
```
219+
220+
### Synchronization
221+
222+
As mentioned in the previous part, using `std::mutex` inside async isn't always a good idea because it can lead to **blocking**. But even if you are sure the mutex is uncontended, using a non-async-aware mutex in a coroutine is **dangerous**:
223+
```cpp
224+
std::mutex mtx;
225+
226+
arc::Future<> coro() {
227+
std::unique_lock lock(mtx);
228+
log::info("Before yielding");
229+
co_await arc::yield();
230+
log::info("After yielding");
231+
}
232+
```
233+
234+
Async tasks are **not bound to a specific thread**, so it's entirely possible for this to print:
235+
```
236+
[arc-worker-2] [Mod]: Before yielding
237+
[arc-worker-0] [Mod]: After yielding
238+
```
239+
240+
This would mean that the mutex is locked on worker thread 2, but unlocked on worker thread 0, which will **almost certainly lead to a crash**. OS primitives like `std::mutex` expect to be unlocked by the same thread that locked them.
241+
242+
There are two solutions to this: either ensure you *never* hold a lock across a `co_await` point, or use an async-aware `arc::Mutex`. Async mutexes are somewhat slower than OS counterparts, but for most tasks it isn't an issue:
243+
244+
```cpp
245+
arc::Mutex<int> mtx;
246+
247+
arc::Future<> coro() {
248+
auto lock = co_await mtx.lock();
249+
*lock = 42;
250+
251+
// Completely safe to yield or do any other async work here
252+
co_await arc::yield();
253+
}
254+
```
255+
256+
## Enabling features
257+
258+
To reduce compile times, mods by default only include essential features of Arc (core and time utilities). You can opt into using other features by adding these lines in your CMakeLists.txt
259+
260+
```cmake
261+
# Enable all features
262+
set(ARC_FEATURE_FULL ON CACHE BOOL "" FORCE)
263+
264+
# Or, you can choose to enable granularly
265+
set(ARC_FEATURE_NET ON CACHE BOOL "" FORCE)
266+
set(ARC_FEATURE_TIME ON CACHE BOOL "" FORCE)
267+
set(ARC_FEATURE_SIGNAL ON CACHE BOOL "" FORCE)
268+
set(ARC_FEATURE_DEBUG ON CACHE BOOL "" FORCE)
269+
270+
271+
# All the lines above should go *before* this line in CMakeLists.txt:
272+
add_subdirectory($ENV{GEODE_SDK} ${CMAKE_CURRENT_BINARY_DIR}/geode)
273+
```

0 commit comments

Comments
 (0)