superlock 1.4.3 → 1.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -11
- package/package.json +22 -3
- package/src/fair.js +72 -80
- package/src/linked-list.js +23 -0
package/README.md
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://superlock.js.org/">
|
|
3
|
+
<picture>
|
|
4
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/anatomy/wordmark.svg">
|
|
5
|
+
<img alt="superlock" src="docs/anatomy/wordmark-light.svg" width="640">
|
|
6
|
+
</picture>
|
|
7
|
+
</a>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<p align="center" class="subtitle">The minimal Node.js concurrency toolkit.</p>
|
|
11
|
+
|
|
12
|
+
<p align="center" class="badges">
|
|
13
|
+
<a href="https://github.com/Kikobeats/superlock/releases"><img alt="Last version" src="https://img.shields.io/github/v/tag/Kikobeats/superlock?style=flat-square"></a>
|
|
14
|
+
<a href="https://coveralls.io/github/Kikobeats/superlock"><img alt="Coverage Status" src="https://img.shields.io/coverallsCoverage/github/Kikobeats/superlock?style=flat-square"></a>
|
|
15
|
+
<a href="https://www.npmjs.com/package/superlock"><img alt="NPM Status" src="https://img.shields.io/npm/dm/superlock?style=flat-square"></a>
|
|
16
|
+
</p>
|
|
6
17
|
|
|
7
18
|
## Why
|
|
8
19
|
|
|
@@ -10,7 +21,7 @@
|
|
|
10
21
|
|
|
11
22
|
- **Simple**: Designed for usage with `async` and `await`
|
|
12
23
|
- **Powerful**: Mutex, semaphore, and fair queuing
|
|
13
|
-
- **Secure**: Auto lock release
|
|
24
|
+
- **Secure**: Auto lock release to avoid deadlocks
|
|
14
25
|
- **Lightweight**: No dependencies
|
|
15
26
|
- **Well-tested**: 100% code coverage
|
|
16
27
|
|
|
@@ -24,7 +35,14 @@ $ npm install superlock --save
|
|
|
24
35
|
|
|
25
36
|
### as mutex
|
|
26
37
|
|
|
27
|
-
The lock is a mutex by default:
|
|
38
|
+
The lock is a mutex by default: one caller holds it, the rest wait in arrival order.
|
|
39
|
+
|
|
40
|
+
<a href="https://superlock.js.org/#/?id=as-mutex">
|
|
41
|
+
<picture>
|
|
42
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/anatomy/mutex.svg">
|
|
43
|
+
<img alt="A coin-feed escapement: tokens queue in a column, a pusher one token thick carries exactly one at a time to a press, and the next drops in only after release()." src="docs/anatomy/mutex-light.svg">
|
|
44
|
+
</picture>
|
|
45
|
+
</a>
|
|
28
46
|
|
|
29
47
|
```js
|
|
30
48
|
const { setTimeout } = require('timers/promises')
|
|
@@ -46,7 +64,14 @@ console.log(executions)
|
|
|
46
64
|
|
|
47
65
|
### as semaphore
|
|
48
66
|
|
|
49
|
-
|
|
67
|
+
Call `withLock(n)` with `n` as the maximum concurrency for the lock. Up to `n` callers hold it at once; a freed slot goes to the oldest waiter:
|
|
68
|
+
|
|
69
|
+
<a href="https://superlock.js.org/#/?id=as-semaphore">
|
|
70
|
+
<picture>
|
|
71
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/anatomy/semaphore.svg">
|
|
72
|
+
<img alt="Twin lanes fed by one column: a diverter drops the oldest waiting token into whichever of two lanes is free, and each lane releases on its own." src="docs/anatomy/semaphore-light.svg">
|
|
73
|
+
</picture>
|
|
74
|
+
</a>
|
|
50
75
|
|
|
51
76
|
```js
|
|
52
77
|
const { setTimeout } = require('timers/promises')
|
|
@@ -72,6 +97,13 @@ console.log(executions)
|
|
|
72
97
|
|
|
73
98
|
`withFairQueue` is [fair queuing](https://en.wikipedia.org/wiki/Fair_queuing): one queue per key, and a free slot rotates across those keys. The same key is still served in arrival order. Callers that omit the key share one queue.
|
|
74
99
|
|
|
100
|
+
<a href="https://superlock.js.org/#/?id=as-fair-queue">
|
|
101
|
+
<picture>
|
|
102
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/anatomy/fair-queue.svg">
|
|
103
|
+
<img alt="An indexing table: one column per key around a rotating dial that feeds one press, visiting the keys in turn so a deep column cannot starve the others." src="docs/anatomy/fair-queue-light.svg">
|
|
104
|
+
</picture>
|
|
105
|
+
</a>
|
|
106
|
+
|
|
75
107
|
```js
|
|
76
108
|
const { withFairQueue } = require('superlock')
|
|
77
109
|
|
|
@@ -184,7 +216,7 @@ const release = await queue(undefined, key)
|
|
|
184
216
|
release()
|
|
185
217
|
```
|
|
186
218
|
|
|
187
|
-
One key is served in arrival order. Across keys, a free slot goes to the
|
|
219
|
+
One key is served in arrival order. Across keys, a free slot goes to the waiting key that has gone longest without a turn; keys that never had one go first, in arrival order. A key that still holds a slot waits behind keys that hold none. Calls that arrive while a round is being served wait until every call that was already waiting has had its turn.
|
|
188
220
|
|
|
189
221
|
A missing key shares one queue. Any other value, including `null`, is its own queue. `null` and a missing signal do not cancel. A value that is not an `AbortSignal` throws `TypeError`. Aborting after the slot was granted does nothing.
|
|
190
222
|
|
|
@@ -264,7 +296,7 @@ How many callers are waiting for a slot. On a fair queue that is the total acros
|
|
|
264
296
|
|
|
265
297
|
## License
|
|
266
298
|
|
|
267
|
-
**superlock** © [Kiko Beats](https://kikobeats.com), released under the [MIT](https://github.com/Kikobeats/
|
|
268
|
-
Authored and maintained by [Kiko Beats](https://kikobeats.com) with help from [contributors](https://github.com/Kikobeats/
|
|
299
|
+
**superlock** © [Kiko Beats](https://kikobeats.com), released under the [MIT](https://github.com/Kikobeats/superlock/blob/master/LICENSE.md) License.<br>
|
|
300
|
+
Authored and maintained by [Kiko Beats](https://kikobeats.com) with help from [contributors](https://github.com/Kikobeats/superlock/contributors).
|
|
269
301
|
|
|
270
302
|
> [kikobeats.com](https://kikobeats.com) · GitHub [Kiko Beats](https://github.com/Kikobeats) · X [@Kikobeats](https://x.com/Kikobeats)
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "superlock",
|
|
3
|
-
"description": "
|
|
4
|
-
"homepage": "https://
|
|
5
|
-
"version": "1.4.
|
|
3
|
+
"description": "The minimal Node.js concurrency toolkit: mutex, semaphore and fair queue for async/await, with no dependencies.",
|
|
4
|
+
"homepage": "https://superlock.js.org/",
|
|
5
|
+
"version": "1.4.5",
|
|
6
6
|
"types": "src/index.d.ts",
|
|
7
7
|
"main": "src/index.js",
|
|
8
8
|
"exports": {
|
|
@@ -39,12 +39,22 @@
|
|
|
39
39
|
"@commitlint/cli": "latest",
|
|
40
40
|
"@commitlint/config-conventional": "latest",
|
|
41
41
|
"ava": "7",
|
|
42
|
+
"browser-sync": "latest",
|
|
42
43
|
"c8": "latest",
|
|
43
44
|
"ci-publish": "latest",
|
|
45
|
+
"concurrently": "latest",
|
|
46
|
+
"cssnano": "latest",
|
|
47
|
+
"cssnano-preset-advanced": "latest",
|
|
44
48
|
"finepack": "latest",
|
|
45
49
|
"git-authors-cli": "latest",
|
|
46
50
|
"github-generate-release": "latest",
|
|
51
|
+
"gulp": "5",
|
|
52
|
+
"gulp-concat": "latest",
|
|
53
|
+
"gulp-postcss": "latest",
|
|
54
|
+
"gulp-uglify": "latest",
|
|
47
55
|
"nano-staged": "latest",
|
|
56
|
+
"postcss": "latest",
|
|
57
|
+
"postcss-focus": "latest",
|
|
48
58
|
"simple-git-hooks": "latest",
|
|
49
59
|
"standard": "latest",
|
|
50
60
|
"standard-version": "latest"
|
|
@@ -56,10 +66,14 @@
|
|
|
56
66
|
"src"
|
|
57
67
|
],
|
|
58
68
|
"scripts": {
|
|
69
|
+
"build": "gulp build",
|
|
59
70
|
"clean": "rm -rf node_modules",
|
|
60
71
|
"contributors": "(npx git-authors-cli && npx finepack && git add package.json && git commit -m 'build: contributors' --no-verify) || true",
|
|
61
72
|
"coverage": "c8 report --reporter=text-lcov > coverage/lcov.info",
|
|
73
|
+
"dev": "concurrently \"gulp\" \"npm run dev:server\"",
|
|
74
|
+
"dev:server": "browser-sync start --server --files \"index.html, README.md, static/**/*.(css|js)\"",
|
|
62
75
|
"lint": "standard",
|
|
76
|
+
"og": "node docs/og/build.mjs",
|
|
63
77
|
"postrelease": "npm run release:tags && npm run release:github && (ci-publish || npm publish --access=public)",
|
|
64
78
|
"prerelease": "npm run contributors",
|
|
65
79
|
"pretest": "npm run lint",
|
|
@@ -75,6 +89,11 @@
|
|
|
75
89
|
"!test/helpers.js"
|
|
76
90
|
]
|
|
77
91
|
},
|
|
92
|
+
"c8": {
|
|
93
|
+
"include": [
|
|
94
|
+
"src/**/*.js"
|
|
95
|
+
]
|
|
96
|
+
},
|
|
78
97
|
"commitlint": {
|
|
79
98
|
"extends": [
|
|
80
99
|
"@commitlint/config-conventional"
|
package/src/fair.js
CHANGED
|
@@ -3,90 +3,89 @@
|
|
|
3
3
|
const LinkedList = require('./linked-list')
|
|
4
4
|
|
|
5
5
|
const SHARED = Symbol('shared')
|
|
6
|
-
const NONE = Symbol('none')
|
|
7
|
-
|
|
8
|
-
// Map keys use SameValueZero, so NaN is one key and === cannot find it.
|
|
9
|
-
const sameKey = (a, b) => a === b || (Number.isNaN(a) && Number.isNaN(b))
|
|
10
6
|
|
|
11
7
|
module.exports = (slots = 1) => {
|
|
12
|
-
const
|
|
13
|
-
const
|
|
8
|
+
const keys = new Map()
|
|
9
|
+
const turns = new LinkedList()
|
|
10
|
+
let newestWithoutTurn
|
|
14
11
|
let waiting = 0
|
|
15
|
-
let last = NONE
|
|
16
12
|
let seq = 0
|
|
17
13
|
let barrier = 0
|
|
18
14
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
15
|
+
const entryOf = key => {
|
|
16
|
+
let entry = keys.get(key)
|
|
17
|
+
if (!entry) {
|
|
18
|
+
entry = { key, queue: new LinkedList(), holders: 0 }
|
|
19
|
+
entry.turnNode = turns.insertAfter(newestWithoutTurn, entry)
|
|
20
|
+
newestWithoutTurn = entry.turnNode
|
|
21
|
+
keys.set(key, entry)
|
|
22
|
+
}
|
|
23
|
+
return entry
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// The holder is the cursor and is not on the ring yet. Put that key
|
|
30
|
-
// at the head so the next grant starts on the keys already waiting
|
|
31
|
-
// and reaches the holder last. At the tail, the cursor would step
|
|
32
|
-
// into keys that arrive later and skip the ones already in front.
|
|
33
|
-
const ringNode = sameKey(key, last)
|
|
34
|
-
? ring.prepend(key)
|
|
35
|
-
: ring.enqueue(key)
|
|
36
|
-
entry = { key, queue: new LinkedList(), ringNode }
|
|
37
|
-
queues.set(key, entry)
|
|
26
|
+
const leaveTurns = entry => {
|
|
27
|
+
if (entry.turnNode === newestWithoutTurn) {
|
|
28
|
+
newestWithoutTurn = entry.turnNode.prev
|
|
38
29
|
}
|
|
39
|
-
|
|
40
|
-
waiter.entry = entry
|
|
41
|
-
waiter.joinSeq = ++seq
|
|
42
|
-
waiter.node = entry.queue.enqueue(waiter)
|
|
30
|
+
turns.remove(entry.turnNode)
|
|
43
31
|
}
|
|
44
32
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
// ring is not circular, so the walk jumps from the tail back to the head.
|
|
51
|
-
const selectNode = () => {
|
|
52
|
-
const current = queues.get(last)
|
|
53
|
-
const start =
|
|
54
|
-
current && current.ringNode.next ? current.ringNode.next : ring.head
|
|
55
|
-
let node = start
|
|
56
|
-
do {
|
|
57
|
-
if (eligible(queues.get(node.data))) return node
|
|
58
|
-
node = node.next || ring.head
|
|
59
|
-
} while (node !== start)
|
|
33
|
+
const markTurn = entry => {
|
|
34
|
+
if (entry.turnNode === newestWithoutTurn) {
|
|
35
|
+
newestWithoutTurn = entry.turnNode.prev
|
|
36
|
+
}
|
|
37
|
+
turns.moveToTail(entry.turnNode)
|
|
60
38
|
}
|
|
61
39
|
|
|
62
|
-
const
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
40
|
+
const forgetIfIdle = entry => {
|
|
41
|
+
if (entry.holders > 0 || entry.queue.size() > 0) return
|
|
42
|
+
leaveTurns(entry)
|
|
43
|
+
keys.delete(entry.key)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const inRound = entry => entry.queue.head.data.joinSeq <= barrier
|
|
47
|
+
|
|
48
|
+
// A smaller holder count does not outrank an older turn.
|
|
49
|
+
const longestWithoutTurn = () => {
|
|
50
|
+
let holding
|
|
51
|
+
for (let node = turns.head; node; node = node.next) {
|
|
52
|
+
const entry = node.data
|
|
53
|
+
if (entry.queue.size() === 0 || !inRound(entry)) continue
|
|
54
|
+
if (entry.holders === 0) return entry
|
|
55
|
+
if (!holding) holding = entry
|
|
70
56
|
}
|
|
71
|
-
|
|
72
|
-
const waiter = entry.queue.dequeue()
|
|
73
|
-
waiting--
|
|
74
|
-
// A key that just left the ring must not stay as `last`. The next grant
|
|
75
|
-
// would miss it in the map and restart at the head, skipping whoever
|
|
76
|
-
// was behind it.
|
|
77
|
-
if (entry.queue.size() === 0) last = drop(entry)
|
|
78
|
-
else last = entry.key
|
|
79
|
-
waiter.acquire()
|
|
57
|
+
return holding
|
|
80
58
|
}
|
|
81
59
|
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
60
|
+
const nextEntry = () => {
|
|
61
|
+
const entry = longestWithoutTurn()
|
|
62
|
+
if (entry) return entry
|
|
63
|
+
barrier = seq
|
|
64
|
+
return longestWithoutTurn()
|
|
85
65
|
}
|
|
86
66
|
|
|
87
|
-
const
|
|
67
|
+
const take = entry => {
|
|
88
68
|
--slots
|
|
89
|
-
|
|
69
|
+
entry.holders++
|
|
70
|
+
markTurn(entry)
|
|
71
|
+
let released = false
|
|
72
|
+
return () => {
|
|
73
|
+
if (released) return
|
|
74
|
+
released = true
|
|
75
|
+
entry.holders--
|
|
76
|
+
markTurn(entry)
|
|
77
|
+
forgetIfIdle(entry)
|
|
78
|
+
++slots
|
|
79
|
+
grant()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const grant = () => {
|
|
84
|
+
if (waiting === 0) return
|
|
85
|
+
const entry = nextEntry()
|
|
86
|
+
const waiter = entry.queue.dequeue()
|
|
87
|
+
waiting--
|
|
88
|
+
waiter.acquire(take(entry))
|
|
90
89
|
}
|
|
91
90
|
|
|
92
91
|
const lock = (signal, key) =>
|
|
@@ -99,31 +98,24 @@ module.exports = (slots = 1) => {
|
|
|
99
98
|
) {
|
|
100
99
|
throw new TypeError('`signal` needs to be an AbortSignal.')
|
|
101
100
|
}
|
|
102
|
-
const id = key === undefined ? SHARED : key
|
|
103
101
|
if (signal?.aborted) return resolve(null)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return acquire(resolve)
|
|
107
|
-
}
|
|
102
|
+
const entry = entryOf(key === undefined ? SHARED : key)
|
|
103
|
+
if (!lock.isLocked()) return resolve(take(entry))
|
|
108
104
|
|
|
109
|
-
const waiter = {
|
|
110
|
-
enqueue(
|
|
105
|
+
const waiter = { joinSeq: ++seq, acquire: resolve }
|
|
106
|
+
waiter.node = entry.queue.enqueue(waiter)
|
|
107
|
+
waiting++
|
|
111
108
|
|
|
112
109
|
if (signal != null) {
|
|
113
110
|
const onAbort = () => {
|
|
114
|
-
const entry = waiter.entry
|
|
115
111
|
entry.queue.remove(waiter.node)
|
|
116
112
|
waiting--
|
|
117
|
-
|
|
118
|
-
const current = queues.get(last) === entry
|
|
119
|
-
const cursor = drop(entry)
|
|
120
|
-
if (current) last = cursor
|
|
121
|
-
}
|
|
113
|
+
forgetIfIdle(entry)
|
|
122
114
|
resolve(null)
|
|
123
115
|
}
|
|
124
|
-
waiter.acquire =
|
|
116
|
+
waiter.acquire = release => {
|
|
125
117
|
signal.removeEventListener('abort', onAbort)
|
|
126
|
-
|
|
118
|
+
resolve(release)
|
|
127
119
|
}
|
|
128
120
|
signal.addEventListener('abort', onAbort, { once: true })
|
|
129
121
|
}
|
package/src/linked-list.js
CHANGED
|
@@ -31,6 +31,29 @@ module.exports = class LinkedList {
|
|
|
31
31
|
return node
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
insertAfter (previous, data) {
|
|
35
|
+
if (!previous) return this.prepend(data)
|
|
36
|
+
const node = new Node(data)
|
|
37
|
+
node.prev = previous
|
|
38
|
+
node.next = previous.next
|
|
39
|
+
if (previous.next) previous.next.prev = node
|
|
40
|
+
else this.tail = node
|
|
41
|
+
previous.next = node
|
|
42
|
+
this.length++
|
|
43
|
+
return node
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
moveToTail (node) {
|
|
47
|
+
if (node === this.tail) return
|
|
48
|
+
if (node.prev) node.prev.next = node.next
|
|
49
|
+
else this.head = node.next
|
|
50
|
+
node.next.prev = node.prev
|
|
51
|
+
node.prev = this.tail
|
|
52
|
+
node.next = undefined
|
|
53
|
+
this.tail.next = node
|
|
54
|
+
this.tail = node
|
|
55
|
+
}
|
|
56
|
+
|
|
34
57
|
dequeue () {
|
|
35
58
|
if (!this.head) return
|
|
36
59
|
const { data } = this.head
|