Skip to content

Commit 4c1ad50

Browse files
committed
5. add shared_memory.PLock for singleton process with multiprocessing.shared_memory, support linux and windows.
1 parent 729af5f commit 4c1ad50

File tree

5 files changed

+196
-4
lines changed

5 files changed

+196
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
2. add `key_type` arg to `sqlite.KV`, to support `int` key.
44
3. add `utils.cut_file` to cut file with `a+b` mode to limit the file size
55
4. add recheck for `utils.set_pid_file`
6-
5.
6+
5. add `shared_memory.PLock` for singleton process with `multiprocessing.shared_memory`, support linux and windows.
77

88

99
### 1.2.3 (2025-03-07)

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ print(morebuiltins.__file__)
139139
2.2 `Crontab` - Crontab python parser.
140140

141141

142-
## 3. morebuiltins.functools
142+
## 3. morebuiltins.funcs
143143

144144
3.1 `lru_cache_ttl` - A Least Recently Used (LRU) cache with a Time To Live (TTL) feature.
145145

@@ -258,6 +258,11 @@ print(morebuiltins.__file__)
258258
## 17. morebuiltins.sqlite
259259

260260

261+
## 18. morebuiltins.shared_memory
262+
263+
18.1 `PLock` - A simple process lock using shared memory, for singleton control.
264+
265+
261266
<!-- end -->
262267

263268
## cmd utils

doc.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1338,7 +1338,7 @@ Demo:
13381338
---
13391339

13401340

1341-
## 3. morebuiltins.functools
1341+
## 3. morebuiltins.funcs
13421342

13431343

13441344

@@ -2669,3 +2669,48 @@ Examples usage:
26692669
## 17. morebuiltins.sqlite
26702670

26712671

2672+
## 18. morebuiltins.shared_memory
2673+
2674+
2675+
2676+
18.1 `PLock` - A simple process lock using shared memory, for singleton control.
2677+
2678+
2679+
```python
2680+
Use `with` context or `close_atexit` to ensure the shared memory is closed in case the process crashes.
2681+
2682+
Args:
2683+
name (str): name of the shared memory
2684+
force (bool, optional): whether to force rewrite the existing shared memory. Defaults to False.
2685+
close_atexit (bool, optional): whether to close the shared memory at process exit. Defaults to False, to use __del__ or __exit__ instead.
2686+
2687+
Demo:
2688+
2689+
>>> test_pid = 123456 # test pid, often set to None for current process
2690+
>>> plock = PLock("test_lock", force=False, close_atexit=True, pid=test_pid)
2691+
>>> plock.locked
2692+
True
2693+
>>> try:
2694+
... plock2 = PLock("test_lock", force=False, close_atexit=True, pid=test_pid + 1)
2695+
... raise RuntimeError("Should not be here")
2696+
... except RuntimeError:
2697+
... True
2698+
True
2699+
>>> plock3 = PLock("test_lock", force=True, close_atexit=True, pid=test_pid + 1)
2700+
>>> plock3.locked
2701+
True
2702+
>>> plock.locked
2703+
False
2704+
>>> PLock.wait_for_free(name="test_lock", timeout=0.1, interval=0.01)
2705+
False
2706+
>>> plock.close()
2707+
>>> plock3.close()
2708+
>>> PLock.wait_for_free(name="test_lock", timeout=0.1, interval=0.01)
2709+
True
2710+
2711+
```
2712+
2713+
2714+
---
2715+
2716+

morebuiltins/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
__all__ = [
33
"morebuiltins.utils",
44
"morebuiltins.date",
5-
"morebuiltins.functools",
5+
"morebuiltins.funcs",
66
"morebuiltins.ipc",
77
"morebuiltins.request",
88
"morebuiltins.download_python",

morebuiltins/shared_memory.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import atexit
2+
import os
3+
import time
4+
from multiprocessing import shared_memory
5+
6+
7+
__all__ = [
8+
"PLock",
9+
]
10+
11+
12+
class PLock:
13+
"""A simple process lock using shared memory, for singleton control.
14+
Use `with` context or `close_atexit` to ensure the shared memory is closed in case the process crashes.
15+
16+
Args:
17+
name (str): name of the shared memory
18+
force (bool, optional): whether to force rewrite the existing shared memory. Defaults to False.
19+
close_atexit (bool, optional): whether to close the shared memory at process exit. Defaults to False, to use __del__ or __exit__ instead.
20+
21+
Demo:
22+
23+
>>> test_pid = 123456 # test pid, often set to None for current process
24+
>>> plock = PLock("test_lock", force=False, close_atexit=True, pid=test_pid)
25+
>>> plock.locked
26+
True
27+
>>> try:
28+
... plock2 = PLock("test_lock", force=False, close_atexit=True, pid=test_pid + 1)
29+
... raise RuntimeError("Should not be here")
30+
... except RuntimeError:
31+
... True
32+
True
33+
>>> plock3 = PLock("test_lock", force=True, close_atexit=True, pid=test_pid + 1)
34+
>>> plock3.locked
35+
True
36+
>>> plock.locked
37+
False
38+
>>> PLock.wait_for_free(name="test_lock", timeout=0.1, interval=0.01)
39+
False
40+
>>> plock.close()
41+
>>> plock3.close()
42+
>>> PLock.wait_for_free(name="test_lock", timeout=0.1, interval=0.01)
43+
True
44+
"""
45+
46+
DEFAULT_SIZE = 4 # 4 bytes, means 2^32 = 4GB
47+
DEFAULT_BYTEORDER = "little"
48+
49+
def __init__(self, name: str, force=False, close_atexit=False, pid=None):
50+
self.name = name
51+
self.pid = pid or os.getpid()
52+
self.force = force
53+
self.shm = None
54+
# whether the shared memory is closed
55+
self._closed = False
56+
if close_atexit:
57+
atexit.register(self.close)
58+
self.init()
59+
60+
@staticmethod
61+
def wait_for_free(name: str, timeout=3, interval=0.1):
62+
"""Wait for the shared memory to be free."""
63+
start = time.time()
64+
while time.time() - start < timeout:
65+
try:
66+
shared_memory.SharedMemory(name=name).close()
67+
time.sleep(interval)
68+
except FileNotFoundError:
69+
return True
70+
return False
71+
72+
def init(self):
73+
if self._closed:
74+
raise RuntimeError("Already closed")
75+
ok = True
76+
try:
77+
self.shm = shared_memory.SharedMemory(
78+
name=self.name, create=True, size=self.DEFAULT_SIZE
79+
)
80+
except FileExistsError:
81+
self.shm = shared_memory.SharedMemory(name=self.name)
82+
if not self.force:
83+
ok = False
84+
if not ok:
85+
mem_pid = self.mem_pid
86+
self.close()
87+
raise RuntimeError(
88+
f"Locked by another process: {mem_pid} != {self.pid}(self)"
89+
)
90+
self.set_mem_pid()
91+
if not self.locked:
92+
raise ValueError(
93+
f"Failed to write PID to shared memory. {self.mem_pid} != {self.pid}(self)"
94+
)
95+
96+
def set_mem_pid(self, pid=None):
97+
if pid is None:
98+
pid = self.pid
99+
self.shm.buf[: self.DEFAULT_SIZE] = pid.to_bytes(
100+
self.DEFAULT_SIZE, byteorder=self.DEFAULT_BYTEORDER
101+
)
102+
103+
def get_mem_pid(self):
104+
return int.from_bytes(
105+
self.shm.buf[: self.DEFAULT_SIZE], byteorder=self.DEFAULT_BYTEORDER
106+
)
107+
108+
@property
109+
def mem_pid(self):
110+
return self.get_mem_pid()
111+
112+
@property
113+
def locked(self):
114+
return self.mem_pid == self.pid
115+
116+
def close(self):
117+
if self._closed:
118+
return
119+
self._closed = True
120+
if self.shm:
121+
try:
122+
self.shm.close()
123+
if self.locked:
124+
# only unlink if self.pid is the owner of the shared memory
125+
self.shm.unlink()
126+
except Exception:
127+
pass
128+
129+
def __enter__(self):
130+
return self
131+
132+
def __exit__(self, *_, **_kwargs):
133+
self.close()
134+
135+
def __del__(self):
136+
self.close()
137+
138+
139+
if __name__ == "__main__":
140+
import doctest
141+
142+
doctest.testmod()

0 commit comments

Comments
 (0)