Python脚本如何规避进程卡死问题

wen python案例 33

Python脚本如何规避进程卡死问题:从检测到恢复的完整指南

📖 目录导读

  1. 进程卡死的常见原因与诊断方法
  2. 超时控制:防止单次操作无限等待
  3. 多线程与异步编程的陷阱与规避
  4. 守护进程与看门狗机制
  5. 资源泄露检测与自动清理
  6. 常见问答与实战案例

进程卡死的常见原因与诊断方法

很多开发者都遇到过这样的场景:运行了一个Python脚本,然后它既不报错也不退出,CPU占用率忽高忽低或降为0,终端像被“冻住”了一样,这就是典型的进程卡死(Hung Process) 问题。

Python脚本如何规避进程卡死问题

1 卡死的主要原因

  • 死锁(Deadlock):多线程同时等待对方释放资源
  • 无限循环(Infinite Loop):循环条件永远无法满足
  • 阻塞I/O无超时:网络请求、文件读写卡在阻塞状态
  • 内存泄漏:长时间运行导致内存爆满,进程被系统杀死
  • 第三方库的未处理异常:某些C扩展库内部崩溃无响应

2 如何诊断卡死

推荐使用strace(Linux)或Process Monitor(Windows):

# Linux下查看进程卡在什么系统调用
strace -p <PID>

Python内置的faulthandler模块可以在信号中断时打印线程栈:

import faulthandler
faulthandler.enable()
# 程序运行过程中,按Ctrl+\\(SIGQUIT)即可打印所有线程堆栈

问答环节
Q:我的脚本有时卡死,但偶尔又能正常运行,如何复现诊断?
A:可以在关键代码段增加traceback.print_stack()日志输出,或者使用sys.settrace()动态跟踪函数调用。


超时控制:防止单次操作无限等待

最常见的卡死场景是某个函数调用永远不返回,在Python中,有几种成熟的超时实现方式。

1 使用signal.alarm(Unix/Linux)

适合简单场景,但会中断整个进程的当前线程:

import signal
def handler(signum, frame):
    raise TimeoutError("函数执行超时")
signal.signal(signal.SIGALRM, handler)
signal.alarm(5)  # 5秒超时
try:
    long_running_function()
except TimeoutError:
    print("操作已超时")
finally:
    signal.alarm(0)  # 取消闹钟

2 使用concurrent.futures(跨平台推荐)

将阻塞操作放入单独线程,设置超时:

from concurrent.futures import ThreadPoolExecutor, TimeoutError
with ThreadPoolExecutor(max_workers=1) as executor:
    future = executor.submit(network_request, url)
    try:
        result = future.result(timeout=10)  # 10秒超时
    except TimeoutError:
        future.cancel()  # 尝试取消
        # 注意:线程级别的取消不一定能中断正在执行的代码

3 使用func-timeout

第三方库func-timeout提供了更可靠的超时控制,支持信号和线程两种模式:

pip install func-timeout
from func_timeout import func_timeout, FunctionTimedOut
try:
    result = func_timeout(5, long_running_function, args=(param,))
except FunctionTimedOut:
    print("函数执行超时,已终止")

问答环节
Q:超时后进程真的能完全杀死吗?
A:不一定,如果阻塞发生在C扩展层,Python主线程无法抢占它,此时需要配合“看门狗”机制,直接从外部杀死并重启进程。


多线程与异步编程的陷阱与规避

1 死锁的典型模式

线程A持有锁1,等待锁2;线程B持有锁2,等待锁1:

import threading
lock1 = threading.Lock()
lock2 = threading.Lock()
def thread_a():
    with lock1:
        with lock2:  # 可能死锁
            pass
def thread_b():
    with lock2:
        with lock1:  # 可能死锁
            pass

规避方案

  • 固定锁的获取顺序(如总是先获取id较小的锁)
  • 使用threading.RLock(可重入锁)
  • 使用timeout参数:
if lock1.acquire(timeout=5):
    try:
        if lock2.acquire(timeout=5):
            # 处理
            lock2.release()
    finally:
        lock1.release()

2 asyncio中的卡死陷阱

Python的asyncio虽然协作式,但如果你在协程中调用了阻塞的同步函数(如time.sleeprequests.get),整个事件循环都会卡住:

import asyncio
import time
async def bad_coro():
    time.sleep(10)  # 阻塞!相当于卡死整个事件循环
# 正确做法:使用asyncio.sleep代替
async def good_coro():
    await asyncio.sleep(10)

解决方案

  • 将阻塞I/O放入loop.run_in_executor中执行
  • 使用aiohttp代替requests
  • 使用asyncio.wait_for设置超时
async def safe_http_call():
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(None, requests.get, 'https://www.example.com')
    return result

守护进程与看门狗机制

有些服务化脚本必须在24小时稳定运行,此时需要建立外部看门狗(Watchdog)

1 简单心跳方案

主进程定期向共享文件写入时间戳:

# 心跳写入
import time, os
HEARTBEAT_FILE = '/tmp/script_heartbeat'
def heartbeat_loop():
    while True:
        with open(HEARTBEAT_FILE, 'w') as f:
            f.write(str(time.time()))
        time.sleep(60)  # 每分钟更新
# 在另一个监控脚本中
def check_health():
    if os.path.exists(HEARTBEAT_FILE):
        last_time = float(open(HEARTBEAT_FILE).read())
        if time.time() - last_time > 120:  # 两分钟未更新
            print("进程卡死或已崩溃,进行重启")
            os.system('systemctl restart myscript')

2 使用supervisorsystemd(推荐)

systemd服务配置可指定自动重启策略:

[Unit]
Description=My Python Service
[Service]
Type=simple
ExecStart=/usr/bin/python /opt/myscript.py
Restart=on-failure
RestartSec=10
StartLimitBurst=3
[Install]
WantedBy=multi-user.target

当脚本卡死、崩溃或超时被外部杀死时,systemd自动重启。

3 内嵌看门狗线程

在脚本内部启动一个监控线程,检测主循环是否“前进”:

import threading, time
class Watchdog:
    def __init__(self, timeout=30):
        self.timeout = timeout
        self.last_feed = time.time()
        t = threading.Thread(target=self._monitor, daemon=True)
        t.start()
    def feed(self):
        self.last_feed = time.time()
    def _monitor(self):
        while True:
            if time.time() - self.last_feed > self.timeout:
                print("看门狗触发:主进程卡死,准备退出")
                os._exit(1)  # 强制退出
            time.sleep(5)

问答环节
Q:内嵌看门狗和外部看门狗哪个更好?
A:外部看门狗更可靠,因为即使Python解释器完全卡死,外部进程依然能监控,内嵌看门狗适合快速检测到特定业务逻辑的卡死。


资源泄露检测与自动清理

渐进式卡死往往由资源泄露引起:文件句柄、网络连接、数据库连接逐渐耗尽,最终导致进程无响应。

1 使用with语句保证释放

# 错误:忘记close
f = open('file.txt', 'r')
data = f.read()
# 正确:自动关闭
with open('file.txt', 'r') as f:
    data = f.read()

2 连接池超时设置

使用requests时务必设置超时和连接池限制:

import requests
from requests.adapters import HTTPAdapter
session = requests.Session()
adapter = HTTPAdapter(pool_connections=10, pool_maxsize=10)
session.mount('https://', adapter)
try:
    resp = session.get('https://www.example.com', timeout=(3, 5))
except requests.exceptions.Timeout:
    print("请求超时")

3 使用gc模块排查未释放对象

在调试阶段,可开启垃圾回收调试:

import gc
gc.set_debug(gc.DEBUG_LEAK)
# 运行一段时间后
gc.collect()
print("收集到的不可达对象:", gc.garbage)

4 定期清理与重启

对于长期运行的脚本,可采用“优雅重启”策略:

import sys, signal
def graceful_shutdown(sig, frame):
    print("收到重启信号,清理资源...")
    # 关闭数据库连接、文件等
    sys.exit(0)
signal.signal(signal.SIGTERM, graceful_shutdown)
signal.signal(signal.SIGINT, graceful_shutdown)

在外部使用cronsystemd timer每天凌晨重启一次:

# cron表达式
0 3 * * * systemctl restart myscript

常见问答与实战案例

Q1:我的脚本处理大量数据时卡死,但没有异常信息,可能原因是什么?

A:最常见的原因是内存耗尽导致系统OOM Killer杀死了进程,建议:

  • 使用--memory-limit监控内存
  • 分批处理数据,不要一次性加载全部
  • 使用memory_profiler库逐行分析内存

Q2:multiprocessing中的进程卡死如何检测?

A:可以使用Queue加超时机制:

from multiprocessing import Process, Queue
def worker(q):
    result = heavy_computation()
    q.put(result)
q = Queue()
p = Process(target=worker, args=(q,))
p.start()
try:
    result = q.get(timeout=60)
except:
    p.terminate()  # 强制杀死子进程
    p.join()

Q3:如何预防Python脚本被系统SIGSTOP/SIGTSTP信号卡死?

A:SIGSTOP无法被捕获,但可以在启动脚本中添加:

if __name__ == '__main__':
    import resource
    # 设置进程的CPU时间限制
    resource.setrlimit(resource.RLIMIT_CPU, (300, 300))  # 最多5分钟CPU
    # 主程序

实战案例:网络爬虫的卡死防御

一个完整的防御性爬虫脚本框架:

import signal, sys, threading, time
from concurrent.futures import ThreadPoolExecutor, TimeoutError
class SpiderWatchdog:
    def __init__(self, timeout=30):
        self.timeout = timeout
        self.last_activity = time.time()
        self.lock = threading.Lock()
        t = threading.Thread(target=self._monitor, daemon=True)
        t.start()
    def activity(self):
        with self.lock:
            self.last_activity = time.time()
    def _monitor(self):
        while True:
            time.sleep(5)
            with self.lock:
                if time.time() - self.last_activity > self.timeout:
                    print("[看门狗] 爬虫已卡死,重启...")
                    os._exit(1)
def crawl_page(url, watchdog):
    try:
        resp = requests.get(url, timeout=10)
        watchdog.activity()  # 更新活动时间
        return resp.text
    except Exception as e:
        print(f"爬取失败: {e}")
        return None
watchdog = SpiderWatchdog(timeout=60)
executor = ThreadPoolExecutor(max_workers=5)
urls = ["https://www.example.com"] * 100
futures = [executor.submit(crawl_page, url, watchdog) for url in urls]
for future in futures:
    try:
        data = future.result(timeout=15)
    except TimeoutError:
        print("单个任务超时,跳过")

规避Python脚本卡死的核心思路是:主动检测 + 超时管理 + 外部监控,没有万能药,但组合使用以下技术可解决95%以上的卡死问题:

  • 设置所有I/O操作的超时参数
  • 使用concurrent.futuresasyncio.wait_for包装不可控函数
  • 部署systemdsupervisor自动重启
  • 内嵌看门狗线程或进程检测心跳
  • 监控资源使用并设置极限

最后建议:在生产环境中,日志是排查卡死的第一利器,确保所有关键点都有时间戳日志输出,配合ELK(Elasticsearch, Logstash, Kibana)或其他日志系统,可以快速定位卡死发生的代码位置。

抱歉,评论功能暂时关闭!