|
| 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