Files
kefu/wechat_rpa/send_lock.py
2026-08-27 14:04:28 +08:00

194 lines
6.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
发送互斥锁
==========
双引擎并存(引擎 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] 发送互斥锁冒烟测试完成")