194 lines
6.9 KiB
Python
194 lines
6.9 KiB
Python
"""
|
||
发送互斥锁
|
||
==========
|
||
双引擎并存(引擎 A 截图 RPA + 引擎 B 数据直读)时,企业微信窗口是唯一共享
|
||
资源,任意时刻只允许一个发送者触碰它。本模块用 Windows 命名互斥量实现
|
||
跨进程/跨线程的发送互斥:
|
||
|
||
- 拿到锁的引擎才有资格调用 WeChatBot.send_reply();
|
||
- 拿不到的引擎本轮直接跳过,绝不阻塞等待(等待会导致两个引擎互相拖死);
|
||
- 持锁进程/线程崩溃时,Windows 会自动释放互斥量,不会出现"锁泄漏"。
|
||
|
||
非 Windows 环境(CI、单元测试、macOS/Linux 开发机)回退到进程内
|
||
threading.Lock,保证模块可导入、可测试。
|
||
"""
|
||
|
||
import os
|
||
import threading
|
||
import time
|
||
|
||
# 全局唯一的互斥量名。跨进程共享同一命名空间,任意数量的引擎进程都抢同一把锁。
|
||
_MUTEX_NAME = "Global\\WeChatBotSendLock"
|
||
|
||
# WaitForSingleObject 返回码
|
||
_WAIT_OBJECT_0 = 0x00000000
|
||
_WAIT_ABANDONED = 0x00000080 # 拿到了锁,但上一个持有者是崩溃退出的
|
||
_WAIT_TIMEOUT = 0x00000102
|
||
|
||
# 回退锁(非 Windows):进程内互斥,行为与命名互斥量一致
|
||
_FALLBACK_LOCK = threading.Lock()
|
||
|
||
_handle = None
|
||
_init_done = False
|
||
_owner = "" # 当前持有者标识(引擎名:线程名),用于排查与日志
|
||
_owner_until = 0.0 # 持有者预计释放时间(wall clock),仅用于日志统计
|
||
_holder_tid = None # 持有锁的线程 id;防止同一线程重入(嵌套发送)
|
||
|
||
|
||
def _windows_mutex_available() -> bool:
|
||
"""Windows 且能加载 kernel32 时返回 True。"""
|
||
global _handle, _init_done
|
||
if _init_done:
|
||
return _handle is not None
|
||
_init_done = True
|
||
if os.name != "nt":
|
||
return False
|
||
try:
|
||
import ctypes
|
||
from ctypes import wintypes # noqa: F401
|
||
|
||
_handle = ctypes.windll.kernel32.CreateMutexW(None, False, _MUTEX_NAME)
|
||
return bool(_handle)
|
||
except Exception:
|
||
_handle = None
|
||
return False
|
||
|
||
|
||
def try_acquire(owner: str = "", timeout: float = 0.0) -> bool:
|
||
"""非阻塞(或短超时)抢占发送锁。
|
||
|
||
owner 用于日志排查,例如 "engine_b:listener"。timeout 默认 0(不等待),
|
||
调用方应优先用 0——拿不到就下一轮再试,避免双引擎互相阻塞。
|
||
|
||
注意:Windows 命名互斥量对同一线程是可重入的(可重复获取),这不符合
|
||
「任意时刻只有一个发送者」的语义(同一线程内也不允许嵌套发送)。因此
|
||
本函数额外记录持有线程 id,同线程重入一律返回 False。
|
||
"""
|
||
global _owner, _owner_until, _holder_tid
|
||
tid = threading.get_ident()
|
||
if _holder_tid == tid:
|
||
return False
|
||
if _windows_mutex_available():
|
||
try:
|
||
import ctypes
|
||
|
||
wait_ms = int(max(0.0, timeout) * 1000)
|
||
rc = ctypes.windll.kernel32.WaitForSingleObject(_handle, wait_ms)
|
||
# WAIT_ABANDONED 同样代表"这把锁现在归你了",只是上一个持有者是崩溃
|
||
# 退出的。把它当失败直接 return,互斥量就永远留在本线程名下、再也没人
|
||
# 释放——从此整个进程一条消息都发不出去。
|
||
if rc in (_WAIT_OBJECT_0, _WAIT_ABANDONED):
|
||
_owner = str(owner or "")
|
||
_owner_until = time.time() + max(0.0, timeout)
|
||
_holder_tid = tid
|
||
return True
|
||
return False
|
||
except Exception:
|
||
return False
|
||
# threading.Lock 不允许"非阻塞 + 超时"同时出现(ValueError)。默认的
|
||
# timeout=0 正是这种组合,非 Windows 环境下每一次抢锁都会直接抛异常。
|
||
if timeout > 0:
|
||
acquired = _FALLBACK_LOCK.acquire(blocking=True, timeout=timeout)
|
||
else:
|
||
acquired = _FALLBACK_LOCK.acquire(blocking=False)
|
||
if not acquired:
|
||
return False
|
||
_owner = str(owner or "")
|
||
_owner_until = time.time() + max(0.0, timeout)
|
||
_holder_tid = tid
|
||
return True
|
||
|
||
|
||
def release() -> None:
|
||
"""释放发送锁。未持锁时调用是安全的(Windows 互斥量允许)。"""
|
||
global _owner, _owner_until, _holder_tid
|
||
if _holder_tid is not None and _holder_tid != threading.get_ident():
|
||
# 非持有线程尝试释放:忽略,避免误释放他人的锁
|
||
return
|
||
_holder_tid = None
|
||
if _windows_mutex_available():
|
||
try:
|
||
import ctypes
|
||
|
||
ctypes.windll.kernel32.ReleaseMutex(_handle)
|
||
except Exception:
|
||
pass
|
||
else:
|
||
try:
|
||
_FALLBACK_LOCK.release()
|
||
except RuntimeError:
|
||
pass
|
||
_owner = ""
|
||
_owner_until = 0.0
|
||
|
||
|
||
def is_held() -> bool:
|
||
"""当前进程是否持有发送锁(用于诊断日志)。"""
|
||
if _holder_tid is not None:
|
||
return True
|
||
if _windows_mutex_available():
|
||
try:
|
||
import ctypes
|
||
|
||
rc = ctypes.windll.kernel32.WaitForSingleObject(_handle, 0)
|
||
if rc in (_WAIT_OBJECT_0, _WAIT_ABANDONED):
|
||
# 抢到了说明之前没持有,立即释放还原状态。WAIT_ABANDONED 也已经
|
||
# 把所有权交给了本线程,同样必须还回去,否则这次"诊断"会把锁吞掉。
|
||
ctypes.windll.kernel32.ReleaseMutex(_handle)
|
||
return False
|
||
return rc == _WAIT_TIMEOUT # 被别人持有
|
||
except Exception:
|
||
return False
|
||
return _FALLBACK_LOCK.locked()
|
||
|
||
|
||
def holder() -> str:
|
||
"""当前持有者标识(仅本进程视角;跨进程时返回空串)。"""
|
||
return str(_owner or "")
|
||
|
||
|
||
# ── 上下文管理器:with send_lock("engine_b"): ... ───────────────────────────
|
||
class _SendLockCtx:
|
||
def __init__(self, owner: str, timeout: float):
|
||
self.owner = owner
|
||
self.timeout = timeout
|
||
self.acquired = False
|
||
|
||
def __enter__(self):
|
||
self.acquired = try_acquire(self.owner, self.timeout)
|
||
return self.acquired
|
||
|
||
def __exit__(self, exc_type, exc, tb):
|
||
if self.acquired:
|
||
release()
|
||
self.acquired = False
|
||
return False
|
||
|
||
|
||
def lock(owner: str = "", timeout: float = 0.0) -> _SendLockCtx:
|
||
"""用法:with send_lock.lock("engine_b"): do_send()"""
|
||
return _SendLockCtx(owner, timeout)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
# 冒烟测试:两个线程抢同一把锁,各自打印持锁时段
|
||
import random
|
||
|
||
def worker(name):
|
||
for i in range(3):
|
||
if try_acquire(name, 0):
|
||
print(f"[{name}] 第{i}轮拿到锁(持有者={holder()})")
|
||
time.sleep(random.uniform(0.02, 0.08))
|
||
release()
|
||
else:
|
||
print(f"[{name}] 第{i}轮未抢到锁,跳过")
|
||
time.sleep(0.01)
|
||
|
||
t1 = threading.Thread(target=worker, args=("engine_a",))
|
||
t2 = threading.Thread(target=worker, args=("engine_b",))
|
||
t1.start()
|
||
t2.start()
|
||
t1.join()
|
||
t2.join()
|
||
print("[OK] 发送互斥锁冒烟测试完成")
|