├── .github ├── FUNDING.yml └── workflows │ ├── jsr.yml │ ├── test-node.yml │ └── test.yml ├── .gitignore ├── .gitmessage ├── .npmrc ├── LICENSE ├── README.md ├── _raw_semaphore.ts ├── _testutil.ts ├── async_value.ts ├── async_value_test.ts ├── barrier.ts ├── barrier_bench.ts ├── barrier_test.ts ├── deno.jsonc ├── deno.lock ├── ensure_promise.ts ├── ensure_promise_test.ts ├── flush_promises.ts ├── flush_promises_test.ts ├── lock.ts ├── lock_bench.ts ├── lock_test.ts ├── mod.ts ├── mod_test.ts ├── mutex.ts ├── mutex_bench.ts ├── mutex_test.ts ├── notify.ts ├── notify_bench.ts ├── notify_test.ts ├── package-lock.json ├── package.json ├── peek_promise_state.ts ├── peek_promise_state_bench.ts ├── peek_promise_state_test.ts ├── promise_state.ts ├── promise_state_bench.ts ├── promise_state_test.ts ├── queue.ts ├── queue_bench.ts ├── queue_test.ts ├── rw_lock.ts ├── rw_lock_bench.ts ├── rw_lock_test.ts ├── semaphore.ts ├── semaphore_bench.ts ├── semaphore_test.ts ├── stack.ts ├── stack_bench.ts ├── stack_test.ts ├── wait_group.ts ├── wait_group_bench.ts └── wait_group_test.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [lambdalisue] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 15 | -------------------------------------------------------------------------------- /.github/workflows/jsr.yml: -------------------------------------------------------------------------------- 1 | name: jsr 2 | 3 | env: 4 | DENO_VERSION: 1.x 5 | 6 | on: 7 | push: 8 | tags: 9 | - "v*" 10 | 11 | permissions: 12 | contents: read 13 | id-token: write 14 | 15 | jobs: 16 | publish: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | - uses: denoland/setup-deno@v1 23 | with: 24 | deno-version: ${{ env.DENO_VERSION }} 25 | - name: Publish 26 | run: | 27 | deno run -A jsr:@david/publish-on-tag@0.1.3 28 | -------------------------------------------------------------------------------- /.github/workflows/test-node.yml: -------------------------------------------------------------------------------- 1 | name: Test (Node) 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: 22.x 18 | - name: Install deps 19 | run: | 20 | npx jsr install 21 | - name: Test 22 | run: | 23 | npx --yes tsx --test *_test.ts 24 | timeout-minutes: 5 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | env: 4 | DENO_VERSION: 1.x 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | pull_request: 11 | workflow_dispatch: 12 | 13 | jobs: 14 | check: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: denoland/setup-deno@v1 19 | with: 20 | deno-version: ${{ env.DENO_VERSION }} 21 | - name: Format 22 | run: | 23 | deno fmt --check 24 | - name: Lint 25 | run: deno lint 26 | - name: Type check 27 | run: deno task check 28 | 29 | test: 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: denoland/setup-deno@v1 34 | with: 35 | deno-version: ${{ env.DENO_VERSION }} 36 | - name: Test 37 | run: | 38 | deno task test:coverage 39 | timeout-minutes: 5 40 | - run: | 41 | deno task coverage --lcov > coverage.lcov 42 | - uses: codecov/codecov-action@v4 43 | with: 44 | os: ${{ runner.os }} 45 | files: ./coverage.lcov 46 | token: ${{ secrets.CODECOV_TOKEN }} 47 | 48 | jsr-publish: 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@v4 52 | - uses: denoland/setup-deno@v1 53 | with: 54 | deno-version: ${{ env.DENO_VERSION }} 55 | - name: Publish (dry-run) 56 | run: | 57 | deno publish --dry-run 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /.tools 3 | /.deno 4 | -------------------------------------------------------------------------------- /.gitmessage: -------------------------------------------------------------------------------- 1 | 2 | # **Conventional Commits** 3 | # 4 | # [optional scope]: 5 | # 6 | # feat: feature (minor) 7 | # deps: dependencies (minor/patch) 8 | # fix: bug fix (patch) 9 | # refactor: refactoring code 10 | # test: test fix; no code change 11 | # docs: documentation fix; no code change 12 | # style: formatting, missing semi colons, etc; no code change 13 | # chore: updating build tasks, package manager configs, etc; no code change 14 | # 15 | # **Install** 16 | # 17 | # git config commit.template .gitmessage 18 | # 19 | # **Reference** 20 | # 21 | # - https://www.conventionalcommits.org/en/v1.0.0/ 22 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @jsr:registry=https://npm.jsr.io 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 jsr-core 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # asyncutil 2 | 3 | [![JSR](https://jsr.io/badges/@core/asyncutil)](https://jsr.io/@core/asyncutil) 4 | [![Test](https://github.com/jsr-core/asyncutil/actions/workflows/test.yml/badge.svg)](https://github.com/jsr-core/asyncutil/actions/workflows/test.yml) 5 | [![Codecov](https://codecov.io/github/jsr-core/asyncutil/graph/badge.svg?token=pfbLRGU5AM)](https://codecov.io/github/jsr-core/asyncutil) 6 | 7 | Asynchronous primitive utility pack. 8 | 9 | ## Usage 10 | 11 | ### AsyncValue 12 | 13 | `AsyncValue` is a class that wraps a value and allows it to be set 14 | asynchronously. 15 | 16 | ```ts 17 | import { assertEquals } from "@std/assert"; 18 | import { AsyncValue } from "@core/asyncutil/async-value"; 19 | 20 | const v = new AsyncValue(0); 21 | assertEquals(await v.get(), 0); 22 | await v.set(1); 23 | assertEquals(await v.get(), 1); 24 | ``` 25 | 26 | ### Barrier 27 | 28 | `Barrier` is a synchronization primitive that allows multiple tasks to wait 29 | until all of them have reached a certain point of execution before continuing. 30 | 31 | ```ts 32 | import { Barrier } from "@core/asyncutil/barrier"; 33 | 34 | const barrier = new Barrier(3); 35 | 36 | async function worker(id: number) { 37 | console.log(`worker ${id} is waiting`); 38 | await barrier.wait(); 39 | console.log(`worker ${id} is done`); 40 | } 41 | 42 | worker(1); 43 | worker(2); 44 | worker(3); 45 | ``` 46 | 47 | ### ensurePromise 48 | 49 | `ensurePromise` is a utility function that ensures a value is a promise. 50 | 51 | ```ts 52 | import { ensurePromise } from "@core/asyncutil/ensure-promise"; 53 | 54 | const p1 = ensurePromise(Promise.resolve("Resolved promise")); 55 | console.log(await p1); // Resolved promise 56 | 57 | const p2 = ensurePromise("Not a promise"); 58 | console.log(await p2); // Not a promise 59 | ``` 60 | 61 | ### flushPromises 62 | 63 | `flushPromises` flushes all pending promises in the microtask queue. 64 | 65 | ```ts 66 | import { flushPromises } from "@core/asyncutil/flush-promises"; 67 | 68 | let count = 0; 69 | Array.from({ length: 5 }).forEach(() => { 70 | Promise.resolve() 71 | .then(() => count++) 72 | .then(() => count++); 73 | }); 74 | 75 | console.log(count); // 0 76 | await flushPromises(); 77 | console.log(count); // 10 78 | ``` 79 | 80 | ### Lock/RwLock 81 | 82 | `Lock` is a mutual exclusion lock that provides safe concurrent access to a 83 | shared value. 84 | 85 | ```ts 86 | import { AsyncValue } from "@core/asyncutil/async-value"; 87 | import { Lock } from "@core/asyncutil/lock"; 88 | 89 | // Critical section 90 | const count = new Lock(new AsyncValue(0)); 91 | await count.lock(async (count) => { 92 | const v = await count.get(); 93 | count.set(v + 1); 94 | }); 95 | ``` 96 | 97 | `RwLock` is a reader-writer lock implementation that allows multiple concurrent 98 | reads but only one write at a time. Readers can acquire the lock simultaneously 99 | as long as there are no writers holding the lock. Writers block all other 100 | readers and writers until the write operation completes. 101 | 102 | ```ts 103 | import { AsyncValue } from "@core/asyncutil/async-value"; 104 | import { RwLock } from "@core/asyncutil/rw-lock"; 105 | 106 | const count = new RwLock(new AsyncValue(0)); 107 | 108 | // rlock should allow multiple readers at a time 109 | await Promise.all( 110 | [...Array(10)].map(() => { 111 | return count.rlock(async (count) => { 112 | console.log(await count.get()); 113 | }); 114 | }), 115 | ); 116 | 117 | // lock should allow only one writer at a time 118 | await Promise.all( 119 | [...Array(10)].map(() => { 120 | return count.lock(async (count) => { 121 | const v = await count.get(); 122 | console.log(v); 123 | count.set(v + 1); 124 | }); 125 | }), 126 | ); 127 | ``` 128 | 129 | ### Mutex 130 | 131 | `Mutex` is a mutex (mutual exclusion) is a synchronization primitive that grants 132 | exclusive access to a shared resource. 133 | 134 | This is a low-level primitive. Use `Lock` instead of `Mutex` if you need to 135 | access a shared value concurrently. 136 | 137 | ```ts 138 | import { AsyncValue } from "@core/asyncutil/async-value"; 139 | import { Mutex } from "@core/asyncutil/mutex"; 140 | 141 | const count = new AsyncValue(0); 142 | 143 | async function doSomething() { 144 | const v = await count.get(); 145 | await count.set(v + 1); 146 | } 147 | 148 | const mu = new Mutex(); 149 | 150 | // Critical section 151 | { 152 | using _lock = await mu.acquire(); 153 | await doSomething(); 154 | } 155 | ``` 156 | 157 | ### Notify 158 | 159 | `Notify` is an async notifier that allows one or more "waiters" to wait for a 160 | notification. 161 | 162 | ```ts 163 | import { assertEquals } from "@std/assert"; 164 | import { promiseState } from "@core/asyncutil/promise-state"; 165 | import { Notify } from "@core/asyncutil/notify"; 166 | 167 | const notify = new Notify(); 168 | const waiter1 = notify.notified(); 169 | const waiter2 = notify.notified(); 170 | notify.notify(); 171 | assertEquals(await promiseState(waiter1), "fulfilled"); 172 | assertEquals(await promiseState(waiter2), "pending"); 173 | notify.notify(); 174 | assertEquals(await promiseState(waiter1), "fulfilled"); 175 | assertEquals(await promiseState(waiter2), "fulfilled"); 176 | ``` 177 | 178 | ### peekPromiseState 179 | 180 | `peekPromiseState` is used to determine the state of the promise. Mainly for 181 | testing purpose. 182 | 183 | ```typescript 184 | import { peekPromiseState } from "@core/asyncutil/peek-promise-state"; 185 | 186 | const p1 = Promise.resolve("Resolved promise"); 187 | console.log(await peekPromiseState(p1)); // fulfilled 188 | 189 | const p2 = Promise.reject("Rejected promise").catch(() => undefined); 190 | console.log(await peekPromiseState(p2)); // rejected 191 | 192 | const p3 = new Promise(() => undefined); 193 | console.log(await peekPromiseState(p3)); // pending 194 | ``` 195 | 196 | Use `flushPromises` to wait all pending promises to resolve. 197 | 198 | ```typescript 199 | import { flushPromises } from "@core/asyncutil/flush-promises"; 200 | import { peekPromiseState } from "@core/asyncutil/peek-promise-state"; 201 | 202 | const p = Promise.resolve(undefined) 203 | .then(() => {}) 204 | .then(() => {}); 205 | 206 | console.log(await peekPromiseState(p)); // pending 207 | await flushPromises(); 208 | console.log(await peekPromiseState(p)); // fulfilled 209 | ``` 210 | 211 | ### Queue/Stack 212 | 213 | `Queue` is a queue implementation that allows for adding and removing elements, 214 | with optional waiting when popping elements from an empty queue. 215 | 216 | ```ts 217 | import { assertEquals } from "@std/assert"; 218 | import { Queue } from "@core/asyncutil/queue"; 219 | 220 | const queue = new Queue(); 221 | queue.push(1); 222 | queue.push(2); 223 | queue.push(3); 224 | assertEquals(await queue.pop(), 1); 225 | assertEquals(await queue.pop(), 2); 226 | assertEquals(await queue.pop(), 3); 227 | ``` 228 | 229 | `Stack` is a stack implementation that allows for adding and removing elements, 230 | with optional waiting when popping elements from an empty stack. 231 | 232 | ```ts 233 | import { assertEquals } from "@std/assert"; 234 | import { Stack } from "@core/asyncutil/stack"; 235 | 236 | const stack = new Stack(); 237 | stack.push(1); 238 | stack.push(2); 239 | stack.push(3); 240 | assertEquals(await stack.pop(), 3); 241 | assertEquals(await stack.pop(), 2); 242 | assertEquals(await stack.pop(), 1); 243 | ``` 244 | 245 | ### Semaphore 246 | 247 | A semaphore that allows a limited number of concurrent executions of an 248 | operation. 249 | 250 | ```ts 251 | import { Semaphore } from "@core/asyncutil/semaphore"; 252 | 253 | const sem = new Semaphore(5); 254 | const worker = () => { 255 | return sem.lock(async () => { 256 | // do something 257 | }); 258 | }; 259 | await Promise.all([...Array(10)].map(() => worker())); 260 | ``` 261 | 262 | ### WaitGroup 263 | 264 | `WaitGroup` is a synchronization primitive that enables promises to coordinate 265 | and synchronize their execution. It is particularly useful in scenarios where a 266 | specific number of tasks must complete before the program can proceed. 267 | 268 | ```ts 269 | import { delay } from "@std/async/delay"; 270 | import { WaitGroup } from "@core/asyncutil/wait-group"; 271 | 272 | const wg = new WaitGroup(); 273 | 274 | async function worker(id: number) { 275 | wg.add(1); 276 | console.log(`worker ${id} is waiting`); 277 | await delay(100); 278 | console.log(`worker ${id} is done`); 279 | wg.done(); 280 | } 281 | 282 | worker(1); 283 | worker(2); 284 | worker(3); 285 | await wg.wait(); 286 | ``` 287 | 288 | ## License 289 | 290 | The code follows MIT license written in [LICENSE](./LICENSE). Contributors need 291 | to agree that any modifications sent in this repository follow the license. 292 | -------------------------------------------------------------------------------- /_raw_semaphore.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @internal 3 | */ 4 | export class RawSemaphore { 5 | #resolves: (() => void)[] = []; 6 | #value: number; 7 | #size: number; 8 | 9 | /** 10 | * Creates a new semaphore with the specified limit. 11 | * 12 | * @param size The maximum number of times the semaphore can be acquired before blocking. 13 | * @throws {RangeError} if the size is not a positive safe integer. 14 | */ 15 | constructor(size: number) { 16 | if (size <= 0 || !Number.isSafeInteger(size)) { 17 | throw new RangeError( 18 | `size must be a positive safe integer, got ${size}`, 19 | ); 20 | } 21 | this.#value = size; 22 | this.#size = size; 23 | } 24 | 25 | /** 26 | * Returns true if the semaphore is currently locked. 27 | */ 28 | get locked(): boolean { 29 | return this.#value === 0; 30 | } 31 | 32 | /** 33 | * Returns the number of waiters that are waiting for lock release. 34 | */ 35 | get waiterCount(): number { 36 | return this.#resolves.length; 37 | } 38 | 39 | /** 40 | * Acquires the semaphore, blocking until the semaphore is available. 41 | */ 42 | acquire(): Promise { 43 | if (this.#value > 0) { 44 | this.#value -= 1; 45 | return Promise.resolve(); 46 | } else { 47 | const { promise, resolve } = Promise.withResolvers(); 48 | this.#resolves.push(resolve); 49 | return promise; 50 | } 51 | } 52 | 53 | /** 54 | * Releases the semaphore, allowing the next waiting operation to proceed. 55 | */ 56 | release(): void { 57 | const resolve = this.#resolves.shift(); 58 | if (resolve) { 59 | resolve(); 60 | } else if (this.#value < this.#size) { 61 | this.#value += 1; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /_testutil.ts: -------------------------------------------------------------------------------- 1 | // Using `deadline` in `@std/async@1.0.2` cause the following error: 2 | // 'Promise resolution is still pending but the event loop has already resolved' 3 | // So we need to implement `deadline` by ourselves. 4 | export async function deadline( 5 | promise: Promise, 6 | timeout: number, 7 | ): Promise { 8 | const waiter = Promise.withResolvers(); 9 | const timer = setTimeout( 10 | () => waiter.reject(new DOMException("Signal timed out.")), 11 | timeout, 12 | ); 13 | return await Promise.race([ 14 | waiter.promise, 15 | promise.finally(() => clearTimeout(timer)), 16 | ]); 17 | } 18 | -------------------------------------------------------------------------------- /async_value.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A class that wraps a value and allows it to be set asynchronously. 3 | * 4 | * ```ts 5 | * import { assertEquals } from "@std/assert"; 6 | * import { AsyncValue } from "@core/asyncutil/async-value"; 7 | * 8 | * const v = new AsyncValue(0); 9 | * assertEquals(await v.get(), 0); 10 | * await v.set(1); 11 | * assertEquals(await v.get(), 1); 12 | * ``` 13 | */ 14 | export class AsyncValue { 15 | #value: T; 16 | 17 | /** 18 | * Constructs a new AsyncValue with the given initial value. 19 | * 20 | * @param value The initial value. 21 | */ 22 | constructor(value: T) { 23 | this.#value = value; 24 | } 25 | 26 | /** 27 | * Returns the current value. 28 | */ 29 | get(): Promise { 30 | return new Promise((resolve) => resolve(this.#value)); 31 | } 32 | 33 | /** 34 | * Sets the value. 35 | */ 36 | set(value: T): Promise { 37 | return new Promise((resolve) => { 38 | this.#value = value; 39 | resolve(); 40 | }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /async_value_test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@cross/test"; 2 | import { assertEquals } from "@std/assert"; 3 | import { AsyncValue } from "./async_value.ts"; 4 | 5 | test( 6 | "AsyncValue 'get' returns a promise that resolves to the value set by 'set'", 7 | async () => { 8 | const v = new AsyncValue(0); 9 | assertEquals(await v.get(), 0); 10 | await v.set(1); 11 | assertEquals(await v.get(), 1); 12 | }, 13 | ); 14 | -------------------------------------------------------------------------------- /barrier.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A synchronization primitive that allows multiple tasks to wait until all of 3 | * them have reached a certain point of execution before continuing. 4 | * 5 | * A `Barrier` is initialized with a size `n`. Once created, `n` tasks can call 6 | * the `wait` method on the `Barrier`. The `wait` method blocks until `n` tasks 7 | * have called it. Once all `n` tasks have called `wait`, all tasks will 8 | * unblock and continue executing. 9 | * 10 | * ```ts 11 | * import { Barrier } from "@core/asyncutil/barrier"; 12 | * 13 | * const barrier = new Barrier(3); 14 | * 15 | * async function worker(id: number) { 16 | * console.log(`worker ${id} is waiting`); 17 | * await barrier.wait(); 18 | * console.log(`worker ${id} is done`); 19 | * } 20 | * 21 | * worker(1); 22 | * worker(2); 23 | * worker(3); 24 | * ``` 25 | */ 26 | export class Barrier { 27 | #waiter: PromiseWithResolvers = Promise.withResolvers(); 28 | #value: number; 29 | 30 | /** 31 | * Creates a new `Barrier` that blocks until `size` threads have called `wait`. 32 | * 33 | * @param size The number of threads that must reach the barrier before it unblocks. 34 | * @throws {RangeError} if the size is not a positive safe integer. 35 | */ 36 | constructor(size: number) { 37 | if (size <= 0 || !Number.isSafeInteger(size)) { 38 | throw new RangeError( 39 | `size must be a positive safe integer, got ${size}`, 40 | ); 41 | } 42 | this.#value = size; 43 | } 44 | 45 | /** 46 | * Wait for all threads to reach the barrier. 47 | * Blocks until all threads reach the barrier. 48 | */ 49 | wait({ signal }: { signal?: AbortSignal } = {}): Promise { 50 | if (signal?.aborted) { 51 | return Promise.reject(signal.reason); 52 | } 53 | const { promise, resolve, reject } = this.#waiter; 54 | const abort = () => reject(signal!.reason); 55 | signal?.addEventListener("abort", abort, { once: true }); 56 | this.#value -= 1; 57 | if (this.#value === 0) { 58 | resolve(); 59 | } 60 | return promise.finally(() => signal?.removeEventListener("abort", abort)); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /barrier_bench.ts: -------------------------------------------------------------------------------- 1 | import { Barrier as Barrier100 } from "jsr:@core/asyncutil@~1.0.0/barrier"; 2 | import { Barrier } from "./barrier.ts"; 3 | 4 | const length = 1_000; 5 | 6 | Deno.bench({ 7 | name: "current", 8 | fn: async () => { 9 | const barrier = new Barrier(length); 10 | await Promise.all(Array.from({ length }).map(() => barrier.wait())); 11 | }, 12 | group: "Barrier#wait", 13 | baseline: true, 14 | }); 15 | 16 | Deno.bench({ 17 | name: "v1.0.0", 18 | fn: async () => { 19 | const barrier = new Barrier100(length); 20 | await Promise.all(Array.from({ length }).map(() => barrier.wait())); 21 | }, 22 | group: "Barrier#wait", 23 | }); 24 | -------------------------------------------------------------------------------- /barrier_test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@cross/test"; 2 | import { assertEquals, assertRejects, assertThrows } from "@std/assert"; 3 | import { delay } from "@std/async"; 4 | import { Barrier } from "./barrier.ts"; 5 | import { deadline } from "./_testutil.ts"; 6 | 7 | test( 8 | "Barrier 'wait' waits until the number of waiters reached the size specified to the barrier", 9 | async () => { 10 | const barrier = new Barrier(5); 11 | const workers = []; 12 | const results: string[] = []; 13 | for (let i = 0; i < 5; i++) { 14 | workers.push((async () => { 15 | results.push(`before wait ${i}`); 16 | await barrier.wait(); 17 | results.push(`after wait ${i}`); 18 | })()); 19 | } 20 | await Promise.all(workers); 21 | assertEquals(results, [ 22 | "before wait 0", 23 | "before wait 1", 24 | "before wait 2", 25 | "before wait 3", 26 | "before wait 4", 27 | "after wait 0", 28 | "after wait 1", 29 | "after wait 2", 30 | "after wait 3", 31 | "after wait 4", 32 | ]); 33 | }, 34 | ); 35 | 36 | test( 37 | "Barrier 'wait' with non-aborted signal", 38 | async () => { 39 | const controller = new AbortController(); 40 | const barrier = new Barrier(2); 41 | 42 | await assertRejects( 43 | () => deadline(barrier.wait({ signal: controller.signal }), 100), 44 | DOMException, 45 | "Signal timed out.", 46 | ); 47 | }, 48 | ); 49 | 50 | test( 51 | "Barrier 'wait' with signal aborted after delay", 52 | async () => { 53 | const controller = new AbortController(); 54 | const barrier = new Barrier(2); 55 | const reason = new Error("Aborted"); 56 | 57 | delay(50).then(() => controller.abort(reason)); 58 | 59 | await assertRejects( 60 | () => deadline(barrier.wait({ signal: controller.signal }), 100), 61 | Error, 62 | "Aborted", 63 | ); 64 | }, 65 | ); 66 | 67 | test( 68 | "Barrier 'wait' with already aborted signal", 69 | async () => { 70 | const controller = new AbortController(); 71 | const barrier = new Barrier(2); 72 | const reason = new Error("Aborted"); 73 | 74 | controller.abort(reason); 75 | 76 | await assertRejects( 77 | () => deadline(barrier.wait({ signal: controller.signal }), 100), 78 | Error, 79 | "Aborted", 80 | ); 81 | }, 82 | ); 83 | 84 | test( 85 | "Barrier throws RangeError if size is not a positive safe integer", 86 | () => { 87 | assertThrows(() => new Barrier(NaN), RangeError); 88 | assertThrows(() => new Barrier(Infinity), RangeError); 89 | assertThrows(() => new Barrier(-Infinity), RangeError); 90 | assertThrows(() => new Barrier(-1), RangeError); 91 | assertThrows(() => new Barrier(1.1), RangeError); 92 | assertThrows(() => new Barrier(0), RangeError); 93 | }, 94 | ); 95 | -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@core/asyncutil", 3 | "version": "0.0.0", 4 | "exports": { 5 | ".": "./mod.ts", 6 | "./async-value": "./async_value.ts", 7 | "./barrier": "./barrier.ts", 8 | "./ensure-promise": "./ensure_promise.ts", 9 | "./flush-promises": "./flush_promises.ts", 10 | "./lock": "./lock.ts", 11 | "./mutex": "./mutex.ts", 12 | "./notify": "./notify.ts", 13 | "./peek-promise-state": "./peek_promise_state.ts", 14 | "./promise-state": "./promise_state.ts", 15 | "./queue": "./queue.ts", 16 | "./rw-lock": "./rw_lock.ts", 17 | "./semaphore": "./semaphore.ts", 18 | "./stack": "./stack.ts", 19 | "./wait-group": "./wait_group.ts" 20 | }, 21 | "exclude": [ 22 | ".coverage/**" 23 | ], 24 | "publish": { 25 | "include": [ 26 | "**/*.ts", 27 | "README.md", 28 | "LICENSE" 29 | ], 30 | "exclude": [ 31 | "**/*_test.ts", 32 | "**/*_bench.ts", 33 | ".*" 34 | ] 35 | }, 36 | "imports": { 37 | "@core/asyncutil": "./mod.ts", 38 | "@core/asyncutil/async-value": "./async_value.ts", 39 | "@core/asyncutil/barrier": "./barrier.ts", 40 | "@core/asyncutil/ensure-promise": "./ensure_promise.ts", 41 | "@core/asyncutil/flush-promises": "./flush_promises.ts", 42 | "@core/asyncutil/lock": "./lock.ts", 43 | "@core/asyncutil/mutex": "./mutex.ts", 44 | "@core/asyncutil/notify": "./notify.ts", 45 | "@core/asyncutil/peek-promise-state": "./peek_promise_state.ts", 46 | "@core/asyncutil/promise-state": "./promise_state.ts", 47 | "@core/asyncutil/queue": "./queue.ts", 48 | "@core/asyncutil/rw-lock": "./rw_lock.ts", 49 | "@core/asyncutil/semaphore": "./semaphore.ts", 50 | "@core/asyncutil/stack": "./stack.ts", 51 | "@core/asyncutil/wait-group": "./wait_group.ts", 52 | "@core/iterutil": "jsr:@core/iterutil@^0.6.0", 53 | "@core/unknownutil": "jsr:@core/unknownutil@^4.2.0", 54 | "@cross/test": "jsr:@cross/test@^0.0.9", 55 | "@std/assert": "jsr:@std/assert@^1.0.2", 56 | "@std/async": "jsr:@std/async@^1.0.2", 57 | "@std/jsonc": "jsr:@std/jsonc@^1.0.0", 58 | "@std/path": "jsr:@std/path@^1.0.2" 59 | }, 60 | "tasks": { 61 | "check": "deno check ./**/*.ts", 62 | "test": "deno test -A --parallel --shuffle --doc", 63 | "test:coverage": "deno task test --coverage=.coverage", 64 | "coverage": "deno coverage .coverage", 65 | "update": "deno run --allow-env --allow-read --allow-write=. --allow-run=git,deno --allow-net=jsr.io,registry.npmjs.org jsr:@molt/cli ./*.ts", 66 | "update:commit": "deno task -q update --commit --prefix deps: --pre-commit=fmt,lint" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3", 3 | "packages": { 4 | "specifiers": { 5 | "jsr:@core/asyncutil@~1.0.0": "jsr:@core/asyncutil@1.0.2", 6 | "jsr:@core/iterutil@^0.6.0": "jsr:@core/iterutil@0.6.0", 7 | "jsr:@core/unknownutil@^4.2.0": "jsr:@core/unknownutil@4.2.0", 8 | "jsr:@cross/runtime@^1.0.0": "jsr:@cross/runtime@1.0.0", 9 | "jsr:@cross/test@^0.0.9": "jsr:@cross/test@0.0.9", 10 | "jsr:@std/assert@^1.0.2": "jsr:@std/assert@1.0.2", 11 | "jsr:@std/async@^1.0.2": "jsr:@std/async@1.0.3", 12 | "jsr:@std/internal@^1.0.1": "jsr:@std/internal@1.0.1", 13 | "jsr:@std/json@^1.0.0": "jsr:@std/json@1.0.0", 14 | "jsr:@std/jsonc@^1.0.0": "jsr:@std/jsonc@1.0.0", 15 | "jsr:@std/path@^1.0.2": "jsr:@std/path@1.0.2", 16 | "npm:@types/node": "npm:@types/node@18.16.19" 17 | }, 18 | "jsr": { 19 | "@core/asyncutil@1.0.2": { 20 | "integrity": "efb2c41ccf6d9ba481f1fedb87734813530ef64ca2193a89f595cd1483b71476", 21 | "dependencies": [ 22 | "jsr:@core/iterutil@^0.6.0" 23 | ] 24 | }, 25 | "@core/iterutil@0.6.0": { 26 | "integrity": "8de0d0062a515496ae744983941d7e379668c2ee2edf43f63423e8da753828b1" 27 | }, 28 | "@core/unknownutil@4.2.0": { 29 | "integrity": "cc0609f2e9fcfd68783c8c34d588261464114770d83047e93eaa1e13732aaedb" 30 | }, 31 | "@cross/runtime@1.0.0": { 32 | "integrity": "dddecdf99182df13d50279d1e473f715e83d41961c5c22edd7bb0c4c3cf8a76a" 33 | }, 34 | "@cross/test@0.0.9": { 35 | "integrity": "2aa8237a96a2f8f51ccc8fec71135616d223bf4f38dd89ba4f863037c85ddc56", 36 | "dependencies": [ 37 | "jsr:@cross/runtime@^1.0.0" 38 | ] 39 | }, 40 | "@std/assert@1.0.2": { 41 | "integrity": "ccacec332958126deaceb5c63ff8b4eaf9f5ed0eac9feccf124110435e59e49c", 42 | "dependencies": [ 43 | "jsr:@std/internal@^1.0.1" 44 | ] 45 | }, 46 | "@std/async@1.0.3": { 47 | "integrity": "6ed64678db43451683c6c176a21426a2ccd21ba0269ebb2c36133ede3f165792" 48 | }, 49 | "@std/internal@1.0.1": { 50 | "integrity": "6f8c7544d06a11dd256c8d6ba54b11ed870aac6c5aeafff499892662c57673e6" 51 | }, 52 | "@std/json@1.0.0": { 53 | "integrity": "985c1e544918d42e4e84072fc739ac4a19c3a5093292c99742ffcdd03fb6a268" 54 | }, 55 | "@std/jsonc@1.0.0": { 56 | "integrity": "835da212e586f3ef94ab25e8f0e8a7711a86fddbee95ad40c34d6b3d74da1a1b", 57 | "dependencies": [ 58 | "jsr:@std/json@^1.0.0" 59 | ] 60 | }, 61 | "@std/path@1.0.2": { 62 | "integrity": "a452174603f8c620bd278a380c596437a9eef50c891c64b85812f735245d9ec7" 63 | } 64 | }, 65 | "npm": { 66 | "@types/node@18.16.19": { 67 | "integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==", 68 | "dependencies": {} 69 | } 70 | } 71 | }, 72 | "remote": {}, 73 | "workspace": { 74 | "dependencies": [ 75 | "jsr:@core/iterutil@^0.6.0", 76 | "jsr:@core/unknownutil@^4.2.0", 77 | "jsr:@cross/test@^0.0.9", 78 | "jsr:@std/assert@^1.0.2", 79 | "jsr:@std/async@^1.0.2", 80 | "jsr:@std/jsonc@^1.0.0", 81 | "jsr:@std/path@^1.0.2" 82 | ], 83 | "packageJson": { 84 | "dependencies": [ 85 | "npm:@jsr/core__iterutil@^0.6.0-pre.0", 86 | "npm:@jsr/core__unknownutil@^4.2.0", 87 | "npm:@jsr/cross__test", 88 | "npm:@jsr/std__assert", 89 | "npm:@jsr/std__async@^1.0.3", 90 | "npm:@jsr/std__jsonc@^1.0.0-rc.3", 91 | "npm:@jsr/std__path@^1.0.2" 92 | ] 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /ensure_promise.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Ensure that a value is a promise. 3 | * 4 | * It returns the value if it is already a promise, otherwise it returns a 5 | * promise that resolves to the value. 6 | * 7 | * @param value - The value to ensure as a promise. 8 | * @returns A promise that resolves to the value. 9 | * 10 | * ```ts 11 | * import { assertEquals } from "@std/assert"; 12 | * import { ensurePromise } from "@core/asyncutil/ensure-promise"; 13 | * 14 | * assertEquals(await ensurePromise(42), 42); 15 | * assertEquals(await ensurePromise(Promise.resolve(42)), 42); 16 | * ``` 17 | */ 18 | export function ensurePromise(value: T): Promise { 19 | return value instanceof Promise ? value : Promise.resolve(value); 20 | } 21 | -------------------------------------------------------------------------------- /ensure_promise_test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@cross/test"; 2 | import { assertEquals } from "@std/assert"; 3 | import { ensurePromise } from "./ensure_promise.ts"; 4 | 5 | test("ensurePromise() returns the value if it is already a promise", async () => { 6 | const p = Promise.resolve(42); 7 | assertEquals(await ensurePromise(p), 42); 8 | }); 9 | 10 | test("ensurePromise() returns a promise that resolves to the value", async () => { 11 | assertEquals(await ensurePromise(42), 42); 12 | }); 13 | -------------------------------------------------------------------------------- /flush_promises.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Flush all pending promises in the microtask queue. 3 | * 4 | * ```ts 5 | * import { flushPromises } from "@core/asyncutil/flush-promises"; 6 | * 7 | * let count = 0; 8 | * Array.from({ length: 5 }).forEach(() => { 9 | * Promise.resolve() 10 | * .then(() => count++) 11 | * .then(() => count++); 12 | * }); 13 | * 14 | * console.log(count); // 0 15 | * await flushPromises(); 16 | * console.log(count); // 10 17 | * ``` 18 | * 19 | * The original idea comes from [flush-promises] package in npm. 20 | * 21 | * [flush-promises]: https://www.npmjs.com/package/flush-promises 22 | */ 23 | export function flushPromises(): Promise { 24 | return new Promise((resolve) => setTimeout(resolve)); 25 | } 26 | -------------------------------------------------------------------------------- /flush_promises_test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@cross/test"; 2 | import { assertEquals } from "@std/assert"; 3 | import { flushPromises } from "./flush_promises.ts"; 4 | 5 | test( 6 | "flushPromises() flushes all pending promises in the microtask queue", 7 | async () => { 8 | let count = 0; 9 | Array.from({ length: 5 }).forEach(() => { 10 | Promise.resolve() 11 | .then(() => count++) 12 | .then(() => count++); 13 | }); 14 | assertEquals(count, 0); 15 | await flushPromises(); 16 | assertEquals(count, 10); 17 | }, 18 | ); 19 | -------------------------------------------------------------------------------- /lock.ts: -------------------------------------------------------------------------------- 1 | import { RawSemaphore } from "./_raw_semaphore.ts"; 2 | 3 | /** 4 | * A mutual exclusion lock that provides safe concurrent access to a shared value. 5 | * 6 | * ```ts 7 | * import { AsyncValue } from "@core/asyncutil/async-value"; 8 | * import { Lock } from "@core/asyncutil/lock"; 9 | * 10 | * // Critical section 11 | * const count = new Lock(new AsyncValue(0)); 12 | * await count.lock(async (count) => { 13 | * const v = await count.get(); 14 | * count.set(v + 1); 15 | * }); 16 | * ``` 17 | */ 18 | export class Lock { 19 | #sem = new RawSemaphore(1); 20 | #value: T; 21 | 22 | /** 23 | * Constructs a new lock with the given initial value. 24 | * 25 | * @param value The initial value of the lock. 26 | */ 27 | constructor(value: T) { 28 | this.#value = value; 29 | } 30 | 31 | /** 32 | * Returns true if the lock is currently locked, false otherwise. 33 | */ 34 | get locked(): boolean { 35 | return this.#sem.locked; 36 | } 37 | 38 | /** 39 | * Acquires the lock and applies the given function to the shared value, 40 | * returning the result. 41 | * 42 | * @param fn The function to apply to the shared value. 43 | * @returns A Promise that resolves with the result of the function. 44 | */ 45 | async lock(fn: (value: T) => R | PromiseLike): Promise { 46 | await this.#sem.acquire(); 47 | try { 48 | return await fn(this.#value); 49 | } finally { 50 | this.#sem.release(); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lock_bench.ts: -------------------------------------------------------------------------------- 1 | import { Lock as Lock100 } from "jsr:@core/asyncutil@~1.0.0/lock"; 2 | import { Lock } from "./lock.ts"; 3 | 4 | const length = 1_000; 5 | 6 | Deno.bench({ 7 | name: "current", 8 | fn: async () => { 9 | const lock = new Lock(0); 10 | await Promise.all(Array.from({ length }).map(() => lock.lock(() => {}))); 11 | }, 12 | group: "Lock#lock", 13 | baseline: true, 14 | }); 15 | 16 | Deno.bench({ 17 | name: "v1.0.0", 18 | fn: async () => { 19 | const lock = new Lock100(0); 20 | await Promise.all(Array.from({ length }).map(() => lock.lock(() => {}))); 21 | }, 22 | group: "Lock#lock", 23 | }); 24 | -------------------------------------------------------------------------------- /lock_test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@cross/test"; 2 | import { assertEquals } from "@std/assert"; 3 | import { AsyncValue } from "./async_value.ts"; 4 | import { Lock } from "./lock.ts"; 5 | 6 | test( 7 | "Lock Processing over multiple event loops is not atomic", 8 | async () => { 9 | const count = new AsyncValue(0); 10 | const operation = async () => { 11 | const v = await count.get(); 12 | await count.set(v + 1); 13 | }; 14 | await Promise.all([...Array(10)].map(() => operation())); 15 | assertEquals(await count.get(), 1); 16 | }, 17 | ); 18 | 19 | test( 20 | "Lock Processing over multiple event loops is not atomic, but can be changed to atomic by using Lock", 21 | async () => { 22 | const count = new Lock(new AsyncValue(0)); 23 | const operation = () => { 24 | return count.lock(async (count) => { 25 | const v = await count.get(); 26 | await count.set(v + 1); 27 | }); 28 | }; 29 | await Promise.all([...Array(10)].map(() => operation())); 30 | assertEquals(await count.lock((v) => v.get()), 10); 31 | }, 32 | ); 33 | 34 | test( 35 | "Lock 'lock' should allow only one operation at a time", 36 | async () => { 37 | let noperations = 0; 38 | const results: number[] = []; 39 | const count = new Lock(new AsyncValue(0)); 40 | const operation = () => { 41 | return count.lock(async (count) => { 42 | noperations += 1; 43 | results.push(noperations); 44 | const v = await count.get(); 45 | await count.set(v + 1); 46 | noperations -= 1; 47 | }); 48 | }; 49 | await Promise.all([...Array(10)].map(() => operation())); 50 | assertEquals(noperations, 0); 51 | assertEquals(results, [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]); 52 | }, 53 | ); 54 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./async_value.ts"; 2 | export * from "./barrier.ts"; 3 | export * from "./ensure_promise.ts"; 4 | export * from "./flush_promises.ts"; 5 | export * from "./lock.ts"; 6 | export * from "./mutex.ts"; 7 | export * from "./notify.ts"; 8 | export * from "./peek_promise_state.ts"; 9 | export * from "./promise_state.ts"; 10 | export * from "./queue.ts"; 11 | export * from "./rw_lock.ts"; 12 | export * from "./semaphore.ts"; 13 | export * from "./stack.ts"; 14 | export * from "./wait_group.ts"; 15 | -------------------------------------------------------------------------------- /mod_test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@cross/test"; 2 | import { assertArrayIncludes } from "@std/assert"; 3 | import { basename, globToRegExp, join } from "@std/path"; 4 | import { ensure, is } from "@core/unknownutil"; 5 | import { parse } from "@std/jsonc"; 6 | 7 | const excludes = [ 8 | "mod.ts", 9 | "_*.ts", 10 | "*_test.ts", 11 | "*_bench.ts", 12 | ]; 13 | 14 | test("mod.ts must exports all exports in public modules", async () => { 15 | const modExports = await listModExports("./mod.ts"); 16 | const pubExports = []; 17 | for await (const name of iterPublicModules(".")) { 18 | pubExports.push(...await listModExports(`./${name}.ts`)); 19 | } 20 | assertArrayIncludes(modExports, pubExports); 21 | }, { skip: !("Deno" in globalThis) }); 22 | 23 | test("JSR exports must have all exports in mod.ts", async () => { 24 | const jsrExportEntries = await listJsrExportEntries(); 25 | const modExportEntries: [string, string][] = []; 26 | for await (const name of iterPublicModules(".")) { 27 | modExportEntries.push([`./${name.replaceAll("_", "-")}`, `./${name}.ts`]); 28 | } 29 | assertArrayIncludes(jsrExportEntries, modExportEntries); 30 | }, { skip: !("Deno" in globalThis) }); 31 | 32 | async function* iterPublicModules(relpath: string): AsyncIterable { 33 | const patterns = excludes.map((p) => globToRegExp(p)); 34 | const root = join(import.meta.dirname!, relpath); 35 | for await (const entry of Deno.readDir(root)) { 36 | if (!entry.isFile || !entry.name.endsWith(".ts")) continue; 37 | if (patterns.some((p) => p.test(entry.name))) continue; 38 | yield basename(entry.name, ".ts"); 39 | } 40 | } 41 | 42 | async function listModExports(path: string): Promise { 43 | const mod = await import(import.meta.resolve(path)); 44 | return Array.from(Object.keys(mod)); 45 | } 46 | 47 | async function listJsrExportEntries(): Promise<[string, string][]> { 48 | const text = await Deno.readTextFile( 49 | new URL(import.meta.resolve("./deno.jsonc")), 50 | ); 51 | const json = ensure( 52 | parse(text), 53 | is.ObjectOf({ 54 | exports: is.RecordOf(is.String, is.String), 55 | }), 56 | ); 57 | return Object.entries(json.exports); 58 | } 59 | -------------------------------------------------------------------------------- /mutex.ts: -------------------------------------------------------------------------------- 1 | import { RawSemaphore } from "./_raw_semaphore.ts"; 2 | 3 | /** 4 | * A mutex (mutual exclusion) is a synchronization primitive that grants 5 | * exclusive access to a shared resource. 6 | * 7 | * This is a low-level primitive. Use `Lock` instead of `Mutex` if you need to access a shared value 8 | * concurrently. 9 | * 10 | * ```ts 11 | * import { AsyncValue } from "@core/asyncutil/async-value"; 12 | * import { Mutex } from "@core/asyncutil/mutex"; 13 | * 14 | * const count = new AsyncValue(0); 15 | * 16 | * async function doSomething() { 17 | * const v = await count.get(); 18 | * await count.set(v + 1); 19 | * } 20 | * 21 | * const mu = new Mutex(); 22 | * 23 | * // Critical section 24 | * { 25 | * using _lock = await mu.acquire(); 26 | * await doSomething(); 27 | * } 28 | * ``` 29 | */ 30 | export class Mutex { 31 | #sem: RawSemaphore = new RawSemaphore(1); 32 | 33 | /** 34 | * Returns true if the mutex is locked, false otherwise. 35 | */ 36 | get locked(): boolean { 37 | return this.#sem.locked; 38 | } 39 | 40 | /** 41 | * Acquire the mutex and return a promise with disposable that releases the mutex when disposed. 42 | * 43 | * @returns A Promise with Disposable that releases the mutex when disposed. 44 | */ 45 | acquire(): Promise { 46 | return this.#sem.acquire().then(() => ({ 47 | [Symbol.dispose]: () => { 48 | this.#sem.release(); 49 | }, 50 | })); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /mutex_bench.ts: -------------------------------------------------------------------------------- 1 | import { Mutex as Mutex100 } from "jsr:@core/asyncutil@~1.0.0/mutex"; 2 | import { Mutex } from "./mutex.ts"; 3 | 4 | const length = 1_000; 5 | 6 | Deno.bench({ 7 | name: "current", 8 | fn: async () => { 9 | const mutex = new Mutex(); 10 | await Promise.all( 11 | Array.from({ length }).map(async () => { 12 | const lock = await mutex.acquire(); 13 | lock[Symbol.dispose](); 14 | }), 15 | ); 16 | }, 17 | group: "Mutex#wait", 18 | baseline: true, 19 | }); 20 | 21 | Deno.bench({ 22 | name: "v1.0.0", 23 | fn: async () => { 24 | const mutex = new Mutex100(); 25 | await Promise.all( 26 | Array.from({ length }).map(async () => { 27 | const lock = await mutex.acquire(); 28 | lock[Symbol.dispose](); 29 | }), 30 | ); 31 | }, 32 | group: "Mutex#wait", 33 | }); 34 | -------------------------------------------------------------------------------- /mutex_test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@cross/test"; 2 | import { assertEquals } from "@std/assert"; 3 | import { AsyncValue } from "./async_value.ts"; 4 | import { Mutex } from "./mutex.ts"; 5 | 6 | test( 7 | "Mutex Processing over multiple event loops is not atomic", 8 | async () => { 9 | const count = new AsyncValue(0); 10 | const operation = async () => { 11 | const v = await count.get(); 12 | await count.set(v + 1); 13 | }; 14 | await Promise.all([...Array(10)].map(() => operation())); 15 | assertEquals(await count.get(), 1); 16 | }, 17 | ); 18 | 19 | test( 20 | "Mutex Processing over multiple event loops is not atomic, but can be changed to atomic by using Mutex", 21 | async () => { 22 | const mu = new Mutex(); 23 | const count = new AsyncValue(0); 24 | const operation = async () => { 25 | using _lock = await mu.acquire(); 26 | const v = await count.get(); 27 | await count.set(v + 1); 28 | }; 29 | await Promise.all([...Array(10)].map(() => operation())); 30 | assertEquals(await count.get(), 10); 31 | }, 32 | ); 33 | -------------------------------------------------------------------------------- /notify.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Async notifier that allows one or more "waiters" to wait for a notification. 3 | * 4 | * ```ts 5 | * import { assertEquals } from "@std/assert"; 6 | * import { promiseState } from "@core/asyncutil/promise-state"; 7 | * import { Notify } from "@core/asyncutil/notify"; 8 | * 9 | * const notify = new Notify(); 10 | * const waiter1 = notify.notified(); 11 | * const waiter2 = notify.notified(); 12 | * 13 | * notify.notify(); 14 | * assertEquals(await promiseState(waiter1), "fulfilled"); 15 | * assertEquals(await promiseState(waiter2), "pending"); 16 | * 17 | * notify.notify(); 18 | * assertEquals(await promiseState(waiter1), "fulfilled"); 19 | * assertEquals(await promiseState(waiter2), "fulfilled"); 20 | * ``` 21 | */ 22 | export class Notify { 23 | #waiters: PromiseWithResolvers[] = []; 24 | 25 | /** 26 | * Returns the number of waiters that are waiting for notification. 27 | */ 28 | get waiterCount(): number { 29 | return this.#waiters.length; 30 | } 31 | 32 | /** 33 | * Notifies `n` waiters that are waiting for notification. Resolves each of the notified waiters. 34 | * If there are fewer than `n` waiters, all waiters are notified. 35 | * 36 | * @param n The number of waiters to notify. 37 | * @throws {RangeError} if `n` is not a positive safe integer. 38 | */ 39 | notify(n = 1): void { 40 | if (n <= 0 || !Number.isSafeInteger(n)) { 41 | throw new RangeError(`n must be a positive safe integer, got ${n}`); 42 | } 43 | this.#waiters.splice(0, n).forEach(({ resolve }) => resolve()); 44 | } 45 | 46 | /** 47 | * Notifies all waiters that are waiting for notification. Resolves each of the notified waiters. 48 | */ 49 | notifyAll(): void { 50 | this.#waiters.forEach(({ resolve }) => resolve()); 51 | this.#waiters = []; 52 | } 53 | 54 | /** 55 | * Asynchronously waits for notification. The caller's execution is suspended until 56 | * the `notify` method is called. The method returns a Promise that resolves when the caller is notified. 57 | * Optionally takes an AbortSignal to abort the waiting if the signal is aborted. 58 | */ 59 | notified({ signal }: { signal?: AbortSignal } = {}): Promise { 60 | if (signal?.aborted) { 61 | return Promise.reject(signal.reason); 62 | } 63 | const abort = () => { 64 | const waiter = this.#waiters.shift(); 65 | if (waiter) { 66 | waiter.reject(signal!.reason); 67 | } 68 | }; 69 | signal?.addEventListener("abort", abort, { once: true }); 70 | const waiter = Promise.withResolvers(); 71 | this.#waiters.push(waiter); 72 | return waiter.promise.finally(() => { 73 | signal?.removeEventListener("abort", abort); 74 | }); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /notify_bench.ts: -------------------------------------------------------------------------------- 1 | import { Notify as Notify100 } from "jsr:@core/asyncutil@~1.0.0/notify"; 2 | import { Notify } from "./notify.ts"; 3 | 4 | const length = 1_000; 5 | 6 | Deno.bench({ 7 | name: "current", 8 | fn: async () => { 9 | const notify = new Notify(); 10 | const waiter = Promise.all( 11 | Array.from({ length }).map(async () => { 12 | await notify.notified(); 13 | }), 14 | ); 15 | notify.notifyAll(); 16 | await waiter; 17 | }, 18 | group: "Notify#notifyAll", 19 | baseline: true, 20 | }); 21 | 22 | Deno.bench({ 23 | name: "v1.0.0", 24 | fn: async () => { 25 | const notify = new Notify100(); 26 | const waiter = Promise.all( 27 | Array.from({ length }).map(async () => { 28 | await notify.notified(); 29 | }), 30 | ); 31 | notify.notifyAll(); 32 | await waiter; 33 | }, 34 | group: "Notify#notifyAll", 35 | }); 36 | 37 | Deno.bench({ 38 | name: "current", 39 | fn: async () => { 40 | const notify = new Notify(); 41 | const waiter = Promise.all( 42 | Array.from({ length }).map(async () => { 43 | await notify.notified(); 44 | }), 45 | ); 46 | Array 47 | .from({ length: length }, () => notify.notify()) 48 | .forEach(() => notify.notify()); 49 | await waiter; 50 | }, 51 | group: "Notify#notify", 52 | baseline: true, 53 | }); 54 | 55 | Deno.bench({ 56 | name: "v1.0.0", 57 | fn: async () => { 58 | const notify = new Notify100(); 59 | const waiter = Promise.all( 60 | Array.from({ length }).map(async () => { 61 | await notify.notified(); 62 | }), 63 | ); 64 | Array 65 | .from({ length: length }) 66 | .forEach(() => notify.notify()); 67 | await waiter; 68 | }, 69 | group: "Notify#notify", 70 | }); 71 | -------------------------------------------------------------------------------- /notify_test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@cross/test"; 2 | import { delay } from "@std/async/delay"; 3 | import { assertEquals, assertRejects, assertThrows } from "@std/assert"; 4 | import { flushPromises } from "./flush_promises.ts"; 5 | import { peekPromiseState } from "./peek_promise_state.ts"; 6 | import { Notify } from "./notify.ts"; 7 | 8 | test("Notify 'notify' wakes up a single waiter", async () => { 9 | const notify = new Notify(); 10 | const waiter1 = notify.notified(); 11 | const waiter2 = notify.notified(); 12 | assertEquals(notify.waiterCount, 2); 13 | 14 | notify.notify(); 15 | await flushPromises(); 16 | assertEquals(notify.waiterCount, 1); 17 | assertEquals(await peekPromiseState(waiter1), "fulfilled"); 18 | assertEquals(await peekPromiseState(waiter2), "pending"); 19 | 20 | notify.notify(); 21 | await flushPromises(); 22 | assertEquals(notify.waiterCount, 0); 23 | assertEquals(await peekPromiseState(waiter1), "fulfilled"); 24 | assertEquals(await peekPromiseState(waiter2), "fulfilled"); 25 | }); 26 | 27 | test("Notify 'notify' wakes up a multiple waiters", async () => { 28 | const notify = new Notify(); 29 | const waiter1 = notify.notified(); 30 | const waiter2 = notify.notified(); 31 | const waiter3 = notify.notified(); 32 | const waiter4 = notify.notified(); 33 | const waiter5 = notify.notified(); 34 | assertEquals(notify.waiterCount, 5); 35 | 36 | notify.notify(2); 37 | await flushPromises(); 38 | assertEquals(notify.waiterCount, 3); 39 | assertEquals(await peekPromiseState(waiter1), "fulfilled"); 40 | assertEquals(await peekPromiseState(waiter2), "fulfilled"); 41 | assertEquals(await peekPromiseState(waiter3), "pending"); 42 | assertEquals(await peekPromiseState(waiter4), "pending"); 43 | assertEquals(await peekPromiseState(waiter5), "pending"); 44 | 45 | notify.notify(2); 46 | await flushPromises(); 47 | assertEquals(notify.waiterCount, 1); 48 | assertEquals(await peekPromiseState(waiter1), "fulfilled"); 49 | assertEquals(await peekPromiseState(waiter2), "fulfilled"); 50 | assertEquals(await peekPromiseState(waiter3), "fulfilled"); 51 | assertEquals(await peekPromiseState(waiter4), "fulfilled"); 52 | assertEquals(await peekPromiseState(waiter5), "pending"); 53 | 54 | notify.notify(2); 55 | await flushPromises(); 56 | assertEquals(notify.waiterCount, 0); 57 | assertEquals(await peekPromiseState(waiter1), "fulfilled"); 58 | assertEquals(await peekPromiseState(waiter2), "fulfilled"); 59 | assertEquals(await peekPromiseState(waiter3), "fulfilled"); 60 | assertEquals(await peekPromiseState(waiter4), "fulfilled"); 61 | assertEquals(await peekPromiseState(waiter5), "fulfilled"); 62 | }); 63 | 64 | test("Notify 'notifyAll' wakes up all waiters", async () => { 65 | const notify = new Notify(); 66 | const waiter1 = notify.notified(); 67 | const waiter2 = notify.notified(); 68 | assertEquals(notify.waiterCount, 2); 69 | 70 | notify.notifyAll(); 71 | await flushPromises(); 72 | assertEquals(notify.waiterCount, 0); 73 | assertEquals(await peekPromiseState(waiter1), "fulfilled"); 74 | assertEquals(await peekPromiseState(waiter2), "fulfilled"); 75 | }); 76 | 77 | test( 78 | "Notify 'notified' with non-aborted signal", 79 | async () => { 80 | const controller = new AbortController(); 81 | const notify = new Notify(); 82 | 83 | const waiter = notify.notified({ signal: controller.signal }); 84 | assertEquals(await peekPromiseState(waiter), "pending"); 85 | }, 86 | ); 87 | 88 | test( 89 | "Notify 'notified' with signal aborted after delay", 90 | async () => { 91 | const controller = new AbortController(); 92 | const notify = new Notify(); 93 | const reason = new Error("Aborted"); 94 | 95 | delay(100).then(() => controller.abort(reason)); 96 | await assertRejects( 97 | () => notify.notified({ signal: controller.signal }), 98 | Error, 99 | "Aborted", 100 | ); 101 | }, 102 | ); 103 | 104 | test( 105 | "Notify 'notified' with already aborted signal", 106 | async () => { 107 | const controller = new AbortController(); 108 | const notify = new Notify(); 109 | const reason = new Error("Aborted"); 110 | 111 | controller.abort(reason); 112 | await assertRejects( 113 | () => notify.notified({ signal: controller.signal }), 114 | Error, 115 | "Aborted", 116 | ); 117 | }, 118 | ); 119 | 120 | test( 121 | "Notify 'notify' throws RangeError if size is not a positive safe integer", 122 | () => { 123 | const notify = new Notify(); 124 | assertThrows(() => notify.notify(NaN), RangeError); 125 | assertThrows(() => notify.notify(Infinity), RangeError); 126 | assertThrows(() => notify.notify(-Infinity), RangeError); 127 | assertThrows(() => notify.notify(-1), RangeError); 128 | assertThrows(() => notify.notify(1.1), RangeError); 129 | assertThrows(() => notify.notify(0), RangeError); 130 | }, 131 | ); 132 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "asyncutil", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "@core/iterutil": "npm:@jsr/core__iterutil@^0.6.0-pre.0", 9 | "@core/unknownutil": "npm:@jsr/core__unknownutil@^4.2.0", 10 | "@cross/test": "npm:@jsr/cross__test", 11 | "@std/assert": "npm:@jsr/std__assert", 12 | "@std/async": "npm:@jsr/std__async@^1.0.3", 13 | "@std/jsonc": "npm:@jsr/std__jsonc@^1.0.0-rc.3", 14 | "@std/path": "npm:@jsr/std__path@^1.0.2" 15 | } 16 | }, 17 | "node_modules/@core/iterutil": { 18 | "name": "@jsr/core__iterutil", 19 | "version": "0.6.0-pre.0", 20 | "resolved": "https://npm.jsr.io/~/11/@jsr/core__iterutil/0.6.0-pre.0.tgz", 21 | "integrity": "sha512-O/ka3467LURyXdWqvorYUPQirCaEQOueYwO7RSnKRDd1aGYK0yzfEvfGZkYwYC2PRT745+MOi4lsX6SKKMs97A==" 22 | }, 23 | "node_modules/@core/unknownutil": { 24 | "name": "@jsr/core__unknownutil", 25 | "version": "4.2.0", 26 | "resolved": "https://npm.jsr.io/~/11/@jsr/core__unknownutil/4.2.0.tgz", 27 | "integrity": "sha512-HA5TxLa7E1mSNwiG1oRPfeq09SSrr6OH9TIeM5ECy14Vzv3hnsxxQO4il6Nt24HwMwFt2NkJ/RUqWD07Kbyg4g==" 28 | }, 29 | "node_modules/@cross/test": { 30 | "name": "@jsr/cross__test", 31 | "version": "0.0.9", 32 | "resolved": "https://npm.jsr.io/~/11/@jsr/cross__test/0.0.9.tgz", 33 | "integrity": "sha512-zwDSXQHw8n6k/gBj1Q67Td34Lb1PfkzLTggXnNZzcRO9SxcdAlzyOKFCF62kTFM7ZjVPqYvqu2gHzMLtj6cayw==", 34 | "dependencies": { 35 | "@jsr/cross__runtime": "^1.0.0" 36 | } 37 | }, 38 | "node_modules/@jsr/cross__runtime": { 39 | "version": "1.0.0", 40 | "resolved": "https://npm.jsr.io/~/11/@jsr/cross__runtime/1.0.0.tgz", 41 | "integrity": "sha512-wUtjVBTk65ae4AKQRnxD5x3h4vVmopdKAYie/uS01Qolii2XQ81bKtRTvJ4kx133GYYgIAgyl3ihQ0OK8LcPmQ==" 42 | }, 43 | "node_modules/@jsr/std__bytes": { 44 | "version": "1.0.2", 45 | "resolved": "https://npm.jsr.io/~/11/@jsr/std__bytes/1.0.2.tgz", 46 | "integrity": "sha512-bkZ1rllRB1qsxFbPqtO1VAYTW2+3ZDmf6pcy8xihKS33r0Z1ly6/E/5DoapnJsNy05LdnANUySWt5kj/awgGdg==" 47 | }, 48 | "node_modules/@jsr/std__internal": { 49 | "version": "1.0.1", 50 | "resolved": "https://npm.jsr.io/~/11/@jsr/std__internal/1.0.1.tgz", 51 | "integrity": "sha512-ssI1kvluIero6cCfiWNmYItqCR8QpQB+STBJoe/xQVZ79SDpoqjK5VF2Eq/2M+Dz8WbqHVmrXRCaGN162x+Ebw==" 52 | }, 53 | "node_modules/@jsr/std__json": { 54 | "version": "1.0.0-rc.3", 55 | "resolved": "https://npm.jsr.io/~/11/@jsr/std__json/1.0.0-rc.3.tgz", 56 | "integrity": "sha512-nsHLvMmWTir390Ce3BUlteMF94vqRGpy3MVBi1fGmX7PqnWjpkThBmZJ9vpxME5HAkmA2sBaAOd0FqfQYlR0hw==", 57 | "dependencies": { 58 | "@jsr/std__streams": "^1.0.0-rc.4" 59 | } 60 | }, 61 | "node_modules/@jsr/std__streams": { 62 | "version": "1.0.2", 63 | "resolved": "https://npm.jsr.io/~/11/@jsr/std__streams/1.0.2.tgz", 64 | "integrity": "sha512-xaB3PAJKs5wkXmPvoetMatHjXDWiDTwacFGqxmioFZyZzsQ72mJ4v5C9srw0C1KDpCKtEIuZSJWhTwMSziNNgQ==", 65 | "dependencies": { 66 | "@jsr/std__bytes": "^1.0.2-rc.3" 67 | } 68 | }, 69 | "node_modules/@std/assert": { 70 | "name": "@jsr/std__assert", 71 | "version": "1.0.2", 72 | "resolved": "https://npm.jsr.io/~/11/@jsr/std__assert/1.0.2.tgz", 73 | "integrity": "sha512-xujHXXeT3zvMNZeCXiDyfiITaqP4rgH8wqHNUD0Iyr4c2R0Ea//zJ4pASA1utIEIxeVu1jpeSlHU4+pagscqgQ==", 74 | "dependencies": { 75 | "@jsr/std__internal": "^1.0.1" 76 | } 77 | }, 78 | "node_modules/@std/async": { 79 | "name": "@jsr/std__async", 80 | "version": "1.0.3", 81 | "resolved": "https://npm.jsr.io/~/11/@jsr/std__async/1.0.3.tgz", 82 | "integrity": "sha512-j5NZqYHN/czhfjBKh0jvPU5IRhP3Y5Lk7X3qL5ghw0gDSwI8h/kzlxSMV98ML0L6tXN9SvZU8lqa8Q5evtL4sA==" 83 | }, 84 | "node_modules/@std/jsonc": { 85 | "name": "@jsr/std__jsonc", 86 | "version": "1.0.0-rc.3", 87 | "resolved": "https://npm.jsr.io/~/11/@jsr/std__jsonc/1.0.0-rc.3.tgz", 88 | "integrity": "sha512-4qWZ9mrCieAfQQSdLuSPIUqShG0vJbzA0jmm1ON58VbfQGmftRgC1vfVqRAOpgBwm5GhDZzI+MLhRRqEAF8XSg==", 89 | "dependencies": { 90 | "@jsr/std__json": "^1.0.0-rc.3" 91 | } 92 | }, 93 | "node_modules/@std/path": { 94 | "name": "@jsr/std__path", 95 | "version": "1.0.2", 96 | "resolved": "https://npm.jsr.io/~/11/@jsr/std__path/1.0.2.tgz", 97 | "integrity": "sha512-VadQVUlJZhIjRi8RcDQcNzqKcowfEdqntIXhphae0MeHaC1y60OiFealO25WTzBTHqBC58KFNlM7KWH+tepgOg==" 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "dependencies": { 4 | "@core/iterutil": "npm:@jsr/core__iterutil@^0.6.0-pre.0", 5 | "@core/unknownutil": "npm:@jsr/core__unknownutil@^4.2.0", 6 | "@cross/test": "npm:@jsr/cross__test", 7 | "@std/assert": "npm:@jsr/std__assert", 8 | "@std/async": "npm:@jsr/std__async@^1.0.3", 9 | "@std/jsonc": "npm:@jsr/std__jsonc@^1.0.0-rc.3", 10 | "@std/path": "npm:@jsr/std__path@^1.0.2" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /peek_promise_state.ts: -------------------------------------------------------------------------------- 1 | const t = Symbol("pending mark"); 2 | 3 | /** 4 | * Promise state 5 | */ 6 | export type PromiseState = "fulfilled" | "rejected" | "pending"; 7 | 8 | /** 9 | * Peek the current state (fulfilled, rejected, or pending) of the promise. 10 | * 11 | * ```ts 12 | * import { assertEquals } from "@std/assert"; 13 | * import { peekPromiseState } from "@core/asyncutil/peek-promise-state"; 14 | * 15 | * assertEquals(await peekPromiseState(Promise.resolve("value")), "fulfilled"); 16 | * assertEquals(await peekPromiseState(Promise.reject("error")), "rejected"); 17 | * assertEquals(await peekPromiseState(new Promise(() => {})), "pending"); 18 | * ``` 19 | * 20 | * Use {@linkcode https://jsr.io/@core/asyncutil/doc/flush-promises/~/flushPromises flushPromises} 21 | * to wait for all pending promises to be resolved prior to calling this function. 22 | * 23 | * ```ts 24 | * import { assertEquals } from "@std/assert"; 25 | * import { flushPromises } from "@core/asyncutil/flush-promises"; 26 | * import { peekPromiseState } from "@core/asyncutil/peek-promise-state"; 27 | * 28 | * const p = Promise.resolve(undefined) 29 | * .then(() => {}) 30 | * .then(() => {}); 31 | * assertEquals(await peekPromiseState(p), "pending"); 32 | * await flushPromises(); 33 | * assertEquals(await peekPromiseState(p), "fulfilled"); 34 | * ``` 35 | */ 36 | export function peekPromiseState(p: Promise): Promise { 37 | return Promise.race([p, t]).then( 38 | (v) => (v === t ? "pending" : "fulfilled"), 39 | () => "rejected", 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /peek_promise_state_bench.ts: -------------------------------------------------------------------------------- 1 | import { peekPromiseState } from "./peek_promise_state.ts"; 2 | 3 | Deno.bench({ 4 | name: "current", 5 | fn: async () => { 6 | await peekPromiseState(Promise.resolve("fulfilled")); 7 | }, 8 | group: "peekPromiseState (fulfilled)", 9 | baseline: true, 10 | }); 11 | 12 | Deno.bench({ 13 | name: "current", 14 | fn: async () => { 15 | const p = Promise.reject("reject").catch(() => {}); 16 | await peekPromiseState(p); 17 | }, 18 | group: "peekPromiseState (rejected)", 19 | baseline: true, 20 | }); 21 | 22 | Deno.bench({ 23 | name: "current", 24 | fn: async () => { 25 | await peekPromiseState(new Promise(() => {})); 26 | }, 27 | group: "peekPromiseState (pending)", 28 | baseline: true, 29 | }); 30 | -------------------------------------------------------------------------------- /peek_promise_state_test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@cross/test"; 2 | import { assertEquals } from "@std/assert"; 3 | import { flushPromises } from "./flush_promises.ts"; 4 | import { peekPromiseState } from "./peek_promise_state.ts"; 5 | 6 | test( 7 | "peekPromiseState() returns 'fulfilled' for resolved promise", 8 | async () => { 9 | const p = Promise.resolve("Resolved promise"); 10 | assertEquals(await peekPromiseState(p), "fulfilled"); 11 | }, 12 | ); 13 | 14 | test( 15 | "peekPromiseState() returns 'rejected' for rejected promise", 16 | async () => { 17 | const p = Promise.reject("Rejected promise"); 18 | p.catch(() => undefined); // Avoid 'Uncaught (in promise) Rejected promise' 19 | assertEquals(await peekPromiseState(p), "rejected"); 20 | }, 21 | ); 22 | 23 | test( 24 | "peekPromiseState() returns 'pending' for not resolved promise", 25 | async () => { 26 | const p = new Promise(() => undefined); 27 | assertEquals(await peekPromiseState(p), "pending"); 28 | }, 29 | ); 30 | 31 | test("peekPromiseState() return the current state of the promise", async () => { 32 | const p = Promise.resolve(undefined) 33 | .then(() => {}) 34 | .then(() => {}); 35 | assertEquals(await peekPromiseState(p), "pending"); 36 | await flushPromises(); 37 | assertEquals(await peekPromiseState(p), "fulfilled"); 38 | }); 39 | -------------------------------------------------------------------------------- /promise_state.ts: -------------------------------------------------------------------------------- 1 | import { flushPromises } from "./flush_promises.ts"; 2 | import { peekPromiseState, type PromiseState } from "./peek_promise_state.ts"; 3 | 4 | /** 5 | * Return state (fulfilled/rejected/pending) of a promise 6 | * 7 | * ```ts 8 | * import { assertEquals } from "@std/assert"; 9 | * import { promiseState } from "@core/asyncutil/promise-state"; 10 | * 11 | * assertEquals(await promiseState(Promise.resolve("value")), "fulfilled"); 12 | * assertEquals(await promiseState(Promise.reject("error")), "rejected"); 13 | * assertEquals(await promiseState(new Promise(() => {})), "pending"); 14 | * ``` 15 | * 16 | * @deprecated Use {@linkcode https://jsr.io/@core/asyncutil/doc/peek-promise-state/~/peekPromiseState peekPromiseState} with {@linkcode https://jsr.io/@core/asyncutil/doc/flush-promises/~/flushPromises flushPromises} instead. 17 | */ 18 | export async function promiseState(p: Promise): Promise { 19 | await flushPromises(); 20 | return peekPromiseState(p); 21 | } 22 | 23 | export type { PromiseState }; 24 | -------------------------------------------------------------------------------- /promise_state_bench.ts: -------------------------------------------------------------------------------- 1 | import { promiseState } from "./promise_state.ts"; 2 | 3 | Deno.bench({ 4 | name: "current", 5 | fn: async () => { 6 | await promiseState(Promise.resolve("fulfilled")); 7 | }, 8 | group: "promiseState (fulfilled)", 9 | baseline: true, 10 | }); 11 | 12 | Deno.bench({ 13 | name: "current", 14 | fn: async () => { 15 | const p = Promise.reject("reject").catch(() => {}); 16 | await promiseState(p); 17 | }, 18 | group: "promiseState (rejected)", 19 | baseline: true, 20 | }); 21 | 22 | Deno.bench({ 23 | name: "current", 24 | fn: async () => { 25 | await promiseState(new Promise(() => {})); 26 | }, 27 | group: "promiseState (pending)", 28 | baseline: true, 29 | }); 30 | -------------------------------------------------------------------------------- /promise_state_test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@cross/test"; 2 | import { assertEquals } from "@std/assert"; 3 | import { promiseState } from "./promise_state.ts"; 4 | 5 | test( 6 | "promiseState() returns 'fulfilled' for resolved promise", 7 | async () => { 8 | const p = Promise.resolve("Resolved promise"); 9 | assertEquals(await promiseState(p), "fulfilled"); 10 | }, 11 | ); 12 | 13 | test( 14 | "promiseState() returns 'rejected' for rejected promise", 15 | async () => { 16 | const p = Promise.reject("Rejected promise"); 17 | p.catch(() => undefined); // Avoid 'Uncaught (in promise) Rejected promise' 18 | assertEquals(await promiseState(p), "rejected"); 19 | }, 20 | ); 21 | 22 | test( 23 | "promiseState() returns 'pending' for not resolved promise", 24 | async () => { 25 | const p = new Promise(() => undefined); 26 | assertEquals(await promiseState(p), "pending"); 27 | }, 28 | ); 29 | 30 | test("promiseState() returns refreshed status", async () => { 31 | const { promise, resolve } = Promise.withResolvers(); 32 | const p = (async () => { 33 | await promise; 34 | })(); 35 | assertEquals(await promiseState(p), "pending"); 36 | resolve(); 37 | assertEquals(await promiseState(p), "fulfilled"); 38 | }); 39 | -------------------------------------------------------------------------------- /queue.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A queue implementation that allows for adding and removing elements, with optional waiting when 3 | * popping elements from an empty queue. 4 | * 5 | * ```ts 6 | * import { assertEquals } from "@std/assert"; 7 | * import { Queue } from "@core/asyncutil/queue"; 8 | * 9 | * const queue = new Queue(); 10 | * queue.push(1); 11 | * queue.push(2); 12 | * queue.push(3); 13 | * assertEquals(await queue.pop(), 1); 14 | * assertEquals(await queue.pop(), 2); 15 | * assertEquals(await queue.pop(), 3); 16 | * ``` 17 | */ 18 | export class Queue | null> { 19 | #resolves: (() => void)[] = []; 20 | #items: T[] = []; 21 | 22 | /** 23 | * Gets the number of items in the queue. 24 | */ 25 | get size(): number { 26 | return this.#items.length; 27 | } 28 | 29 | /** 30 | * Returns true if the queue is currently locked. 31 | */ 32 | get locked(): boolean { 33 | return this.#resolves.length > 0; 34 | } 35 | 36 | /** 37 | * Adds an item to the end of the queue and notifies any waiting consumers. 38 | */ 39 | push(value: T): void { 40 | this.#items.push(value); 41 | this.#resolves.shift()?.(); 42 | } 43 | 44 | /** 45 | * Removes the next item from the queue, optionally waiting if the queue is currently empty. 46 | * 47 | * @returns A promise that resolves to the next item in the queue. 48 | */ 49 | async pop({ signal }: { signal?: AbortSignal } = {}): Promise { 50 | while (true) { 51 | signal?.throwIfAborted(); 52 | const value = this.#items.shift(); 53 | if (value !== undefined) { 54 | return value; 55 | } 56 | const { promise, resolve, reject } = Promise.withResolvers(); 57 | signal?.addEventListener("abort", () => reject(signal.reason), { 58 | once: true, 59 | }); 60 | this.#resolves.push(resolve); 61 | await promise; 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /queue_bench.ts: -------------------------------------------------------------------------------- 1 | import { Queue as Queue100 } from "jsr:@core/asyncutil@~1.0.0/queue"; 2 | import { Queue } from "./queue.ts"; 3 | 4 | const length = 1_000; 5 | 6 | Deno.bench({ 7 | name: "current", 8 | fn: async () => { 9 | const queue = new Queue(); 10 | Array 11 | .from({ length }) 12 | .forEach(() => queue.push(1)); 13 | await Promise.all(Array.from({ length }).map(() => queue.pop())); 14 | }, 15 | group: "Queue#push/pop", 16 | baseline: true, 17 | }); 18 | 19 | Deno.bench({ 20 | name: "v1.0.0", 21 | fn: async () => { 22 | const queue = new Queue100(); 23 | Array 24 | .from({ length }) 25 | .forEach(() => queue.push(1)); 26 | await Promise.allSettled(Array.from({ length }).map(() => queue.pop())); 27 | }, 28 | group: "Queue#push/pop", 29 | }); 30 | -------------------------------------------------------------------------------- /queue_test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@cross/test"; 2 | import { delay } from "@std/async/delay"; 3 | import { assertEquals, assertRejects } from "@std/assert"; 4 | import { flushPromises } from "./flush_promises.ts"; 5 | import { peekPromiseState } from "./peek_promise_state.ts"; 6 | import { Queue } from "./queue.ts"; 7 | 8 | test("Queue 'pop' returns pushed items", async () => { 9 | const q = new Queue(); 10 | q.push(1); 11 | q.push(2); 12 | q.push(3); 13 | assertEquals(await q.pop(), 1); 14 | assertEquals(await q.pop(), 2); 15 | assertEquals(await q.pop(), 3); 16 | }); 17 | 18 | test("Queue 'pop' waits for an item is pushed", async () => { 19 | const q = new Queue(); 20 | const popper = q.pop(); 21 | await flushPromises(); 22 | assertEquals(await peekPromiseState(popper), "pending"); 23 | q.push(1); 24 | await flushPromises(); 25 | assertEquals(await peekPromiseState(popper), "fulfilled"); 26 | assertEquals(await popper, 1); 27 | }); 28 | 29 | test("Queue 'pop' with non-aborted signal", async () => { 30 | const controller = new AbortController(); 31 | const q = new Queue(); 32 | const popper = q.pop({ signal: controller.signal }); 33 | await flushPromises(); 34 | assertEquals(await peekPromiseState(popper), "pending"); 35 | }); 36 | 37 | test("Queue 'pop' with signal aborted after delay", async () => { 38 | const controller = new AbortController(); 39 | const q = new Queue(); 40 | const reason = new Error("Aborted"); 41 | 42 | delay(100).then(() => controller.abort(reason)); 43 | 44 | await assertRejects( 45 | () => q.pop({ signal: controller.signal }), 46 | Error, 47 | "Aborted", 48 | ); 49 | }); 50 | 51 | test("Queue 'pop' with signal already aborted", async () => { 52 | const controller = new AbortController(); 53 | const q = new Queue(); 54 | const reason = new Error("Aborted"); 55 | 56 | controller.abort(reason); 57 | 58 | await assertRejects( 59 | () => q.pop({ signal: controller.signal }), 60 | Error, 61 | "Aborted", 62 | ); 63 | }); 64 | 65 | test("Queue with falsy value is accepted", async () => { 66 | const q = new Queue(); 67 | const popper = q.pop(); 68 | await flushPromises(); 69 | assertEquals(await peekPromiseState(popper), "pending"); 70 | q.push(0); 71 | await flushPromises(); 72 | assertEquals(await peekPromiseState(popper), "fulfilled"); 73 | assertEquals(await popper, 0); 74 | }); 75 | 76 | test("Queue with null is accepted", async () => { 77 | const q = new Queue(); 78 | const popper = q.pop(); 79 | await flushPromises(); 80 | assertEquals(await peekPromiseState(popper), "pending"); 81 | q.push(null); 82 | await flushPromises(); 83 | assertEquals(await peekPromiseState(popper), "fulfilled"); 84 | assertEquals(await popper, null); 85 | }); 86 | -------------------------------------------------------------------------------- /rw_lock.ts: -------------------------------------------------------------------------------- 1 | import { RawSemaphore } from "./_raw_semaphore.ts"; 2 | 3 | /** 4 | * A reader-writer lock implementation that allows multiple concurrent reads but only one write at a time. 5 | * Readers can acquire the lock simultaneously as long as there are no writers holding the lock. 6 | * Writers block all other readers and writers until the write operation completes. 7 | * 8 | * ```ts 9 | * import { AsyncValue } from "@core/asyncutil/async-value"; 10 | * import { RwLock } from "@core/asyncutil/rw-lock"; 11 | * 12 | * const count = new RwLock(new AsyncValue(0)); 13 | * 14 | * // rlock should allow multiple readers at a time 15 | * await Promise.all([...Array(10)].map(() => { 16 | * return count.rlock(async (count) => { 17 | * console.log(await count.get()); 18 | * }); 19 | * })); 20 | * 21 | * // lock should allow only one writer at a time 22 | * await Promise.all([...Array(10)].map(() => { 23 | * return count.lock(async (count) => { 24 | * const v = await count.get(); 25 | * console.log(v); 26 | * count.set(v + 1); 27 | * }); 28 | * })); 29 | * ``` 30 | */ 31 | export class RwLock { 32 | #read = new RawSemaphore(1); 33 | #write = new RawSemaphore(1); 34 | #value: T; 35 | 36 | /** 37 | * Creates a new `RwLock` with the specified initial value. 38 | * 39 | * @param value The initial value of the lock. 40 | */ 41 | constructor(value: T) { 42 | this.#value = value; 43 | } 44 | 45 | /** 46 | * Acquires the lock for both reading and writing, and invokes the specified function with the current 47 | * value of the lock. All other readers and writers will be blocked until the function completes. 48 | * 49 | * @param fn The function to invoke. 50 | * @returns A promise that resolves to the return value of the specified function. 51 | */ 52 | async lock(fn: (value: T) => R | PromiseLike): Promise { 53 | await this.#write.acquire(); 54 | try { 55 | await this.#read.acquire(); 56 | try { 57 | return await fn(this.#value); 58 | } finally { 59 | this.#read.release(); 60 | } 61 | } finally { 62 | this.#write.release(); 63 | } 64 | } 65 | 66 | /** 67 | * Acquires the lock for reading, and invokes the specified function with the current value of the lock. 68 | * Other readers can acquire the lock simultaneously, but any writers will be blocked until the function completes. 69 | * 70 | * @param fn The function to invoke. 71 | * @returns A promise that resolves to the return value of the specified function. 72 | */ 73 | async rlock(fn: (value: T) => R | PromiseLike): Promise { 74 | if (this.#write.locked) { 75 | await this.#write.acquire(); 76 | } 77 | try { 78 | // Acquire the read lock without waiting to allow multiple readers to access the lock. 79 | this.#read.acquire(); 80 | try { 81 | return await fn(this.#value); 82 | } finally { 83 | this.#read.release(); 84 | } 85 | } finally { 86 | this.#write.release(); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /rw_lock_bench.ts: -------------------------------------------------------------------------------- 1 | import { RwLock as RwLock100 } from "jsr:@core/asyncutil@~1.0.0/rw-lock"; 2 | import { RwLock } from "./rw_lock.ts"; 3 | 4 | const length = 1_000; 5 | 6 | Deno.bench({ 7 | name: "current", 8 | fn: async () => { 9 | const rwLock = new RwLock(0); 10 | await Promise.all(Array.from({ length }).map(() => rwLock.lock(() => {}))); 11 | }, 12 | group: "RwLock#lock", 13 | baseline: true, 14 | }); 15 | 16 | Deno.bench({ 17 | name: "v1.0.0", 18 | fn: async () => { 19 | const rwLock = new RwLock100(0); 20 | await Promise.all(Array.from({ length }).map(() => rwLock.lock(() => {}))); 21 | }, 22 | group: "RwLock#lock", 23 | }); 24 | 25 | Deno.bench({ 26 | name: "current", 27 | fn: async () => { 28 | const rwLock = new RwLock(0); 29 | await Promise.all(Array.from({ length }).map(() => rwLock.rlock(() => {}))); 30 | }, 31 | group: "RwLock#rlock", 32 | baseline: true, 33 | }); 34 | 35 | Deno.bench({ 36 | name: "v1.0.0", 37 | fn: async () => { 38 | const rwLock = new RwLock100(0); 39 | await Promise.all(Array.from({ length }).map(() => rwLock.rlock(() => {}))); 40 | }, 41 | group: "RwLock#rlock", 42 | }); 43 | -------------------------------------------------------------------------------- /rw_lock_test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@cross/test"; 2 | import { assertEquals } from "@std/assert"; 3 | import { flushPromises } from "./flush_promises.ts"; 4 | import { peekPromiseState } from "./peek_promise_state.ts"; 5 | import { AsyncValue } from "./async_value.ts"; 6 | import { RwLock } from "./rw_lock.ts"; 7 | 8 | test( 9 | "RwLock Processing over multiple event loops is not atomic", 10 | async () => { 11 | const count = new AsyncValue(0); 12 | const operation = async () => { 13 | const v = await count.get(); 14 | await count.set(v + 1); 15 | }; 16 | await Promise.all([...Array(10)].map(() => operation())); 17 | assertEquals(await count.get(), 1); 18 | }, 19 | ); 20 | 21 | test( 22 | "RwLock Processing over multiple event loops is not atomic, but can be changed to atomic by using RwLock", 23 | async () => { 24 | const count = new RwLock(new AsyncValue(0)); 25 | const operation = () => { 26 | return count.lock(async (count) => { 27 | const v = await count.get(); 28 | await count.set(v + 1); 29 | }); 30 | }; 31 | await Promise.all([...Array(10)].map(() => operation())); 32 | assertEquals(await count.lock((v) => v.get()), 10); 33 | }, 34 | ); 35 | 36 | test( 37 | "RwLock 'lock' should allow only one writer at a time", 38 | async () => { 39 | let nwriters = 0; 40 | const results: number[] = []; 41 | const count = new RwLock(new AsyncValue(0)); 42 | const writer = () => { 43 | return count.lock(async (count) => { 44 | nwriters += 1; 45 | results.push(nwriters); 46 | await count.set(await count.get() + 1); 47 | nwriters -= 1; 48 | }); 49 | }; 50 | await Promise.all([...Array(10)].map(() => writer())); 51 | assertEquals(nwriters, 0); 52 | assertEquals(results, [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]); 53 | }, 54 | ); 55 | 56 | test( 57 | "RwLock 'rlock' should allow multiple readers at a time", 58 | async () => { 59 | let nreaders = 0; 60 | const results: number[] = []; 61 | const count = new RwLock(new AsyncValue(0)); 62 | const reader = () => { 63 | return count.rlock(async (count) => { 64 | nreaders += 1; 65 | results.push(nreaders); 66 | assertEquals(await count.get(), 0); 67 | nreaders -= 1; 68 | }); 69 | }; 70 | await Promise.all([...Array(10)].map(() => reader())); 71 | assertEquals(nreaders, 0); 72 | assertEquals(results, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); 73 | }, 74 | ); 75 | 76 | test( 77 | "RwLock 'lock' should block until all readers are done", 78 | async () => { 79 | const count = new RwLock(new AsyncValue(0)); 80 | const { promise, resolve } = Promise.withResolvers(); 81 | const writer = () => { 82 | return count.lock(() => { 83 | // Do nothing 84 | }); 85 | }; 86 | const reader = () => { 87 | return count.rlock(async () => { 88 | await promise; 89 | }); 90 | }; 91 | const r = reader(); 92 | const w = writer(); 93 | await flushPromises(); 94 | assertEquals(await peekPromiseState(r), "pending"); 95 | assertEquals(await peekPromiseState(w), "pending"); 96 | resolve(); 97 | await flushPromises(); 98 | assertEquals(await peekPromiseState(r), "fulfilled"); 99 | assertEquals(await peekPromiseState(w), "fulfilled"); 100 | }, 101 | ); 102 | 103 | test( 104 | "RwLock 'rlock' should block until all writers are done", 105 | async () => { 106 | const count = new RwLock(new AsyncValue(0)); 107 | const { promise, resolve } = Promise.withResolvers(); 108 | const writer = () => { 109 | return count.lock(async () => { 110 | await promise; 111 | }); 112 | }; 113 | const reader = () => { 114 | return count.rlock(() => { 115 | // Do nothing 116 | }); 117 | }; 118 | const w = writer(); 119 | const r = reader(); 120 | await flushPromises(); 121 | assertEquals(await peekPromiseState(w), "pending"); 122 | assertEquals(await peekPromiseState(r), "pending"); 123 | resolve(); 124 | await flushPromises(); 125 | assertEquals(await peekPromiseState(w), "fulfilled"); 126 | assertEquals(await peekPromiseState(r), "fulfilled"); 127 | }, 128 | ); 129 | -------------------------------------------------------------------------------- /semaphore.ts: -------------------------------------------------------------------------------- 1 | import { RawSemaphore } from "./_raw_semaphore.ts"; 2 | 3 | /** 4 | * A semaphore that allows a limited number of concurrent executions of an operation. 5 | * 6 | * ```ts 7 | * import { Semaphore } from "@core/asyncutil/semaphore"; 8 | * 9 | * const sem = new Semaphore(5); 10 | * const worker = () => { 11 | * return sem.lock(async () => { 12 | * // do something 13 | * }); 14 | * }; 15 | * await Promise.all([...Array(10)].map(() => worker())); 16 | * ``` 17 | */ 18 | export class Semaphore { 19 | #sem: RawSemaphore; 20 | 21 | /** 22 | * Creates a new semaphore with the specified limit. 23 | * 24 | * @param size The maximum number of times the semaphore can be acquired before blocking. 25 | * @throws {RangeError} if the size is not a positive safe integer. 26 | */ 27 | constructor(size: number) { 28 | this.#sem = new RawSemaphore(size); 29 | } 30 | 31 | /** 32 | * Returns true if the semaphore is currently locked. 33 | */ 34 | get locked(): boolean { 35 | return this.#sem.locked; 36 | } 37 | 38 | /** 39 | * Returns the number of waiters that are waiting for lock release. 40 | */ 41 | get waiterCount(): number { 42 | return this.#sem.waiterCount; 43 | } 44 | 45 | /** 46 | * Acquires a lock on the semaphore, and invokes the specified function. 47 | * 48 | * @param fn The function to invoke. 49 | * @returns A promise that resolves to the return value of the specified function. 50 | */ 51 | async lock(fn: () => R | PromiseLike): Promise { 52 | await this.#sem.acquire(); 53 | try { 54 | return await fn(); 55 | } finally { 56 | this.#sem.release(); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /semaphore_bench.ts: -------------------------------------------------------------------------------- 1 | import { Semaphore as Semaphore100 } from "jsr:@core/asyncutil@~1.0.0/semaphore"; 2 | import { Semaphore } from "./semaphore.ts"; 3 | 4 | const length = 1_000; 5 | 6 | Deno.bench({ 7 | name: "current", 8 | fn: async () => { 9 | const semaphore = new Semaphore(10); 10 | await Promise.all( 11 | Array.from({ length }).map(() => semaphore.lock(() => {})), 12 | ); 13 | }, 14 | group: "Semaphore#lock", 15 | baseline: true, 16 | }); 17 | 18 | Deno.bench({ 19 | name: "v1.0.0", 20 | fn: async () => { 21 | const semaphore = new Semaphore100(10); 22 | await Promise.all( 23 | Array.from({ length }).map(() => semaphore.lock(() => {})), 24 | ); 25 | }, 26 | group: "Semaphore#lock", 27 | }); 28 | -------------------------------------------------------------------------------- /semaphore_test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@cross/test"; 2 | import { assertEquals, assertThrows } from "@std/assert"; 3 | import { Semaphore } from "./semaphore.ts"; 4 | 5 | test( 6 | "Semaphore regulates the number of workers concurrently running (n=5)", 7 | async () => { 8 | let nworkers = 0; 9 | const results: number[] = []; 10 | const sem = new Semaphore(5); 11 | const worker = () => { 12 | return sem.lock(async () => { 13 | nworkers++; 14 | results.push(nworkers); 15 | await new Promise((resolve) => setTimeout(resolve, 10)); 16 | nworkers--; 17 | }); 18 | }; 19 | await Promise.all([...Array(10)].map(() => worker())); 20 | assertEquals(nworkers, 0); 21 | assertEquals(results, [ 22 | 1, 23 | 2, 24 | 3, 25 | 4, 26 | 5, 27 | 5, 28 | 5, 29 | 5, 30 | 5, 31 | 5, 32 | ]); 33 | }, 34 | ); 35 | 36 | test( 37 | "Semaphore regulates the number of workers concurrently running (n=1)", 38 | async () => { 39 | let nworkers = 0; 40 | const results: number[] = []; 41 | const sem = new Semaphore(1); 42 | const worker = () => { 43 | return sem.lock(async () => { 44 | nworkers++; 45 | results.push(nworkers); 46 | await new Promise((resolve) => setTimeout(resolve, 10)); 47 | nworkers--; 48 | }); 49 | }; 50 | await Promise.all([...Array(10)].map(() => worker())); 51 | assertEquals(nworkers, 0); 52 | assertEquals(results, [ 53 | 1, 54 | 1, 55 | 1, 56 | 1, 57 | 1, 58 | 1, 59 | 1, 60 | 1, 61 | 1, 62 | 1, 63 | ]); 64 | }, 65 | ); 66 | 67 | test( 68 | "Semaphore regulates the number of workers concurrently running (n=10)", 69 | async () => { 70 | let nworkers = 0; 71 | const results: number[] = []; 72 | const sem = new Semaphore(10); 73 | const worker = () => { 74 | return sem.lock(async () => { 75 | nworkers++; 76 | results.push(nworkers); 77 | await new Promise((resolve) => setTimeout(resolve, 10)); 78 | nworkers--; 79 | }); 80 | }; 81 | await Promise.all([...Array(10)].map(() => worker())); 82 | assertEquals(nworkers, 0); 83 | assertEquals(results, [ 84 | 1, 85 | 2, 86 | 3, 87 | 4, 88 | 5, 89 | 6, 90 | 7, 91 | 8, 92 | 9, 93 | 10, 94 | ]); 95 | }, 96 | ); 97 | 98 | test( 99 | "Semaphore throws RangeError if size is not a positive safe integer", 100 | () => { 101 | assertThrows(() => new Semaphore(NaN), RangeError); 102 | assertThrows(() => new Semaphore(Infinity), RangeError); 103 | assertThrows(() => new Semaphore(-Infinity), RangeError); 104 | assertThrows(() => new Semaphore(-1), RangeError); 105 | assertThrows(() => new Semaphore(1.1), RangeError); 106 | assertThrows(() => new Semaphore(0), RangeError); 107 | }, 108 | ); 109 | 110 | test( 111 | "Semaphore.waiterCount returns the number of waiters (n=5)", 112 | async () => { 113 | const befores: number[] = []; 114 | const afters: number[] = []; 115 | const sem = new Semaphore(5); 116 | const worker = (i: number) => { 117 | return sem.lock(async () => { 118 | befores.push(sem.waiterCount); 119 | await new Promise((resolve) => setTimeout(resolve, 10 + i)); 120 | afters.push(sem.waiterCount); 121 | }); 122 | }; 123 | await Promise.all([...Array(10)].map((_, i) => worker(i))); 124 | /** 125 | * Worker 0 |5========5 126 | * Worker 1 |5=========4 127 | * Worker 2 |5==========3 128 | * Worker 3 |5===========2 129 | * Worker 4 |5============1 130 | * Worker 5 |----------4=============0 131 | * Worker 6 |-----------3==============0 132 | * Worker 7 |------------2===============0 133 | * Worker 8 |-------------1================0 134 | * Worker 9 |--------------0=================0 135 | */ 136 | assertEquals(befores, [ 137 | 5, 138 | 5, 139 | 5, 140 | 5, 141 | 5, 142 | 4, 143 | 3, 144 | 2, 145 | 1, 146 | 0, 147 | ]); 148 | assertEquals(afters, [ 149 | 5, 150 | 4, 151 | 3, 152 | 2, 153 | 1, 154 | 0, 155 | 0, 156 | 0, 157 | 0, 158 | 0, 159 | ]); 160 | }, 161 | ); 162 | -------------------------------------------------------------------------------- /stack.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A stack implementation that allows for adding and removing elements, with optional waiting when 3 | * popping elements from an empty stack. 4 | * 5 | * ```ts 6 | * import { assertEquals } from "@std/assert"; 7 | * import { Stack } from "@core/asyncutil/stack"; 8 | * 9 | * const stack = new Stack(); 10 | * stack.push(1); 11 | * stack.push(2); 12 | * stack.push(3); 13 | * assertEquals(await stack.pop(), 3); 14 | * assertEquals(await stack.pop(), 2); 15 | * assertEquals(await stack.pop(), 1); 16 | * ``` 17 | * 18 | * @template T The type of items in the stack. 19 | */ 20 | export class Stack | null> { 21 | #resolves: (() => void)[] = []; 22 | #items: T[] = []; 23 | 24 | /** 25 | * Gets the number of items in the queue. 26 | */ 27 | get size(): number { 28 | return this.#items.length; 29 | } 30 | 31 | /** 32 | * Returns true if the stack is currently locked. 33 | */ 34 | get locked(): boolean { 35 | return this.#resolves.length > 0; 36 | } 37 | 38 | /** 39 | * Adds an item to the top of the stack and notifies any waiting consumers. 40 | * 41 | * @param value The item to add to the stack. 42 | */ 43 | push(value: T): void { 44 | this.#items.push(value); 45 | this.#resolves.shift()?.(); 46 | } 47 | 48 | /** 49 | * Removes the next item from the stack, optionally waiting if the stack is currently empty. 50 | * 51 | * @returns A promise that resolves to the next item in the stack. 52 | */ 53 | async pop({ signal }: { signal?: AbortSignal } = {}): Promise { 54 | while (true) { 55 | signal?.throwIfAborted(); 56 | const value = this.#items.pop(); 57 | if (value !== undefined) { 58 | return value; 59 | } 60 | const { promise, resolve, reject } = Promise.withResolvers(); 61 | signal?.addEventListener("abort", () => reject(signal.reason), { 62 | once: true, 63 | }); 64 | this.#resolves.push(resolve); 65 | await promise; 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /stack_bench.ts: -------------------------------------------------------------------------------- 1 | import { Stack as Stack100 } from "jsr:@core/asyncutil@~1.0.0/stack"; 2 | import { Stack } from "./stack.ts"; 3 | 4 | const length = 1_000; 5 | 6 | Deno.bench({ 7 | name: "current", 8 | fn: async () => { 9 | const stack = new Stack(); 10 | Array 11 | .from({ length }) 12 | .forEach(() => stack.push(1)); 13 | await Promise.all(Array.from({ length }).map(() => stack.pop())); 14 | }, 15 | group: "Stack#push/pop", 16 | baseline: true, 17 | }); 18 | 19 | Deno.bench({ 20 | name: "v1.0.0", 21 | fn: async () => { 22 | const stack = new Stack100(); 23 | Array 24 | .from({ length }) 25 | .forEach(() => stack.push(1)); 26 | await Promise.allSettled(Array.from({ length }).map(() => stack.pop())); 27 | }, 28 | group: "Stack#push/pop", 29 | }); 30 | -------------------------------------------------------------------------------- /stack_test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@cross/test"; 2 | import { delay } from "@std/async/delay"; 3 | import { assertEquals, assertRejects } from "@std/assert"; 4 | import { flushPromises } from "./flush_promises.ts"; 5 | import { peekPromiseState } from "./peek_promise_state.ts"; 6 | import { Stack } from "./stack.ts"; 7 | 8 | test("Stack 'pop' returns pushed items", async () => { 9 | const q = new Stack(); 10 | q.push(1); 11 | q.push(2); 12 | q.push(3); 13 | assertEquals(await q.pop(), 3); 14 | assertEquals(await q.pop(), 2); 15 | assertEquals(await q.pop(), 1); 16 | }); 17 | 18 | test("Stack 'pop' waits for an item is pushed", async () => { 19 | const q = new Stack(); 20 | const popper = q.pop(); 21 | await flushPromises(); 22 | assertEquals(await peekPromiseState(popper), "pending"); 23 | q.push(1); 24 | await flushPromises(); 25 | assertEquals(await peekPromiseState(popper), "fulfilled"); 26 | assertEquals(await popper, 1); 27 | }); 28 | 29 | test("Stack 'pop' with non-aborted signal", async () => { 30 | const controller = new AbortController(); 31 | const q = new Stack(); 32 | const popper = q.pop({ signal: controller.signal }); 33 | await flushPromises(); 34 | assertEquals(await peekPromiseState(popper), "pending"); 35 | }); 36 | 37 | test("Stack 'pop' with signal aborted after delay", async () => { 38 | const controller = new AbortController(); 39 | const q = new Stack(); 40 | const reason = new Error("Aborted"); 41 | 42 | delay(100).then(() => controller.abort(reason)); 43 | 44 | await assertRejects( 45 | () => q.pop({ signal: controller.signal }), 46 | Error, 47 | "Aborted", 48 | ); 49 | }); 50 | 51 | test("Stack 'pop' with signal already aborted", async () => { 52 | const controller = new AbortController(); 53 | const q = new Stack(); 54 | const reason = new Error("Aborted"); 55 | 56 | controller.abort(reason); 57 | 58 | await assertRejects( 59 | () => q.pop({ signal: controller.signal }), 60 | Error, 61 | "Aborted", 62 | ); 63 | }); 64 | 65 | test("Stack with falsy value is accepted", async () => { 66 | const q = new Stack(); 67 | const popper = q.pop(); 68 | await flushPromises(); 69 | assertEquals(await peekPromiseState(popper), "pending"); 70 | q.push(0); 71 | await flushPromises(); 72 | assertEquals(await peekPromiseState(popper), "fulfilled"); 73 | assertEquals(await popper, 0); 74 | }); 75 | 76 | test("Stack with null is accepted", async () => { 77 | const q = new Stack(); 78 | const popper = q.pop(); 79 | await flushPromises(); 80 | assertEquals(await peekPromiseState(popper), "pending"); 81 | q.push(null); 82 | await flushPromises(); 83 | assertEquals(await peekPromiseState(popper), "fulfilled"); 84 | assertEquals(await popper, null); 85 | }); 86 | -------------------------------------------------------------------------------- /wait_group.ts: -------------------------------------------------------------------------------- 1 | import { Notify } from "./notify.ts"; 2 | 3 | /** 4 | * `WaitGroup` is a synchronization primitive that enables promises to coordinate 5 | * and synchronize their execution. It is particularly useful in scenarios where 6 | * a specific number of tasks must complete before the program can proceed. 7 | * 8 | * ```ts 9 | * import { delay } from "@std/async/delay"; 10 | * import { WaitGroup } from "@core/asyncutil/wait-group"; 11 | * 12 | * const wg = new WaitGroup(); 13 | * 14 | * async function worker(id: number) { 15 | * wg.add(1); 16 | * console.log(`worker ${id} is waiting`); 17 | * await delay(100); 18 | * console.log(`worker ${id} is done`); 19 | * wg.done(); 20 | * } 21 | * 22 | * worker(1); 23 | * worker(2); 24 | * worker(3); 25 | * await wg.wait(); 26 | * ``` 27 | */ 28 | export class WaitGroup { 29 | #notify = new Notify(); 30 | #count = 0; 31 | 32 | /** 33 | * Adds the specified `delta` to the WaitGroup counter. If the counter becomes 34 | * zero, it signals all waiting promises to proceed. 35 | * 36 | * @param delta The number to add to the counter. It can be positive or negative. 37 | */ 38 | add(delta: number): void { 39 | if (!Number.isSafeInteger(delta)) { 40 | throw new RangeError(`delta must be a safe integer, got ${delta}`); 41 | } 42 | this.#count += delta; 43 | if (this.#count === 0) { 44 | this.#notify.notifyAll(); 45 | } 46 | } 47 | 48 | /** 49 | * Decrements the WaitGroup counter by 1, equivalent to calling `add(-1)`. 50 | */ 51 | done(): void { 52 | this.add(-1); 53 | } 54 | 55 | /** 56 | * Returns a promise that waits for the WaitGroup counter to reach zero. 57 | * 58 | * @returns A Promise that resolves when the counter becomes zero. 59 | */ 60 | wait({ signal }: { signal?: AbortSignal } = {}): Promise { 61 | return this.#notify.notified({ signal }); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /wait_group_bench.ts: -------------------------------------------------------------------------------- 1 | import { WaitGroup as WaitGroup100 } from "jsr:@core/asyncutil@~1.0.0/wait-group"; 2 | import { WaitGroup } from "./wait_group.ts"; 3 | 4 | const length = 1_000; 5 | 6 | Deno.bench({ 7 | name: "current", 8 | fn: async () => { 9 | const wg = new WaitGroup(); 10 | const waiter = wg.wait(); 11 | Array.from({ length }).forEach(() => wg.add(1)); 12 | Array.from({ length }).forEach(() => wg.done()); 13 | await waiter; 14 | }, 15 | group: "WaitGroup#wait", 16 | baseline: true, 17 | }); 18 | 19 | Deno.bench({ 20 | name: "v1.0.0", 21 | fn: async () => { 22 | const wg = new WaitGroup100(); 23 | const waiter = wg.wait(); 24 | Array.from({ length }).forEach(() => wg.add(1)); 25 | Array.from({ length }).forEach(() => wg.done()); 26 | await waiter; 27 | }, 28 | group: "WaitGroup#wait", 29 | }); 30 | -------------------------------------------------------------------------------- /wait_group_test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@cross/test"; 2 | import { assertEquals, assertRejects, assertThrows } from "@std/assert"; 3 | import { delay } from "@std/async"; 4 | import { WaitGroup } from "./wait_group.ts"; 5 | import { deadline } from "./_testutil.ts"; 6 | 7 | test( 8 | "WaitGroup Ensure WaitGroup synchronizes multiple workers", 9 | async () => { 10 | const wg = new WaitGroup(); 11 | const workers = []; 12 | const results: string[] = []; 13 | for (let i = 0; i < 5; i++) { 14 | workers.push((async () => { 15 | wg.add(1); 16 | results.push(`before wait ${i}`); 17 | await delay(100); 18 | results.push(`after wait ${i}`); 19 | wg.done(); 20 | })()); 21 | } 22 | await wg.wait(); 23 | assertEquals(results, [ 24 | "before wait 0", 25 | "before wait 1", 26 | "before wait 2", 27 | "before wait 3", 28 | "before wait 4", 29 | "after wait 0", 30 | "after wait 1", 31 | "after wait 2", 32 | "after wait 3", 33 | "after wait 4", 34 | ]); 35 | }, 36 | ); 37 | 38 | test( 39 | "WaitGroup 'wait' with non-aborted signal", 40 | async () => { 41 | const controller = new AbortController(); 42 | const wg = new WaitGroup(); 43 | wg.add(1); 44 | await assertRejects( 45 | () => deadline(wg.wait({ signal: controller.signal }), 100), 46 | DOMException, 47 | "Signal timed out.", 48 | ); 49 | }, 50 | ); 51 | 52 | test( 53 | "WaitGroup 'wait' with signal aborted after delay", 54 | async () => { 55 | const controller = new AbortController(); 56 | const wg = new WaitGroup(); 57 | wg.add(1); 58 | 59 | const reason = new Error("Aborted"); 60 | delay(50).then(() => controller.abort(reason)); 61 | 62 | await assertRejects( 63 | () => deadline(wg.wait({ signal: controller.signal }), 100), 64 | Error, 65 | "Aborted", 66 | ); 67 | }, 68 | ); 69 | 70 | test( 71 | "WaitGroup 'wait' with already aborted signal", 72 | async () => { 73 | const controller = new AbortController(); 74 | const wg = new WaitGroup(); 75 | wg.add(1); 76 | 77 | const reason = new Error("Aborted"); 78 | controller.abort(reason); 79 | 80 | await assertRejects( 81 | () => deadline(wg.wait({ signal: controller.signal }), 100), 82 | Error, 83 | "Aborted", 84 | ); 85 | }, 86 | ); 87 | 88 | test( 89 | "WaitGroup 'add' throws RangeError if delta is not a safe integer", 90 | () => { 91 | const wg = new WaitGroup(); 92 | assertThrows(() => wg.add(NaN), RangeError); 93 | assertThrows(() => wg.add(Infinity), RangeError); 94 | assertThrows(() => wg.add(-Infinity), RangeError); 95 | assertThrows(() => wg.add(1.1), RangeError); 96 | }, 97 | ); 98 | --------------------------------------------------------------------------------