☰
从零搭建多模块常驻进程服务骨架:进程守护与优雅退出实践
2026/10/16 3:22:21 网站建设 项目流程

简介:这份资源围绕「进程常驻」这一系统与服务管理主题展开,面向Android及后端开发中需要保障服务长期稳定运行的开发者,内含多个可独立运行的module工程,用于对比实践不同的保活与优先级调整思路。压缩包共69个文件,约16.9MB,以20个java源码、18个xml配置、20个png截图为主,辅以aidl跨进程接口、gradle构建脚本与proguard混淆规则,便于直接导入Android Studio编译调试。资源中两个工程分别对应不同实现路径,涵盖守护进程、服务管理器、启动脚本及Supervisor、NSSM等第三方工具的思路,并涉及nice值与Windows优先级类的调度调整,同时提示日志监控、异常检测与自动重启等健壮性设计。目前已有319人学习,适合希望系统理解进程保活原理、对照源码验证方案的进阶开发者参考。

1. 进程常驻探索:从零搭一个带多个 module 的 service 骨架

很多人第一次写常驻进程,都是从一个while(true)加sleep开始的,跑起来看着没问题,真扔到线上跑两天就发现内存悄悄涨、日志把磁盘写满、进程挂了没人拉。这份资源围绕「进程常驻」这个场景,给了一套带多个 module 的 service 骨架,把进程守护、模块拆分、日志轮转、优雅退出这些常驻服务绕不开的事都摆了出来。它适合两类人:一类是刚接触后台服务、想搞明白一个常驻进程到底该长什么样的新手;另一类是手里已经有一堆脚本、想整理成规范 service 结构的熟手。下面按「它是什么 → 怎么跑起来 → 怎么改」的顺序拆一遍。

2. 架构与目录:多个 module 是怎么挂到一个 service 上的

2.1 常驻进程的核心结构

常驻进程和普通脚本最大的区别,在于它的生命周期不是「跑完就退」,而是「一直活着,随时响应」。这就带来三个必须解决的问题:进程不能因为单个模块异常就整体挂掉、模块之间要能独立启停、退出时要把资源收干净。这份资源用的是一个主进程加多个 module 的结构,主进程负责调度和守护,每个 module 是一个独立的功能单元,通过统一的注册接口挂进来。

常见做法是主进程里维护一个 module 列表,启动时逐个初始化,运行期用一个事件循环驱动,退出时反向逐个关闭。这样单个 module 抛异常可以被捕获并记录,不至于把整个 service 带崩。下面是一个典型的 module 注册与调度骨架:

# main_service.py import time import signal import logging from modules import load_modules logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s" ) logger = logging.getLogger("service") class Service: def __init__(self): self.modules = load_modules() # 扫描并实例化所有 module self.running = True def start(self): for m in self.modules: m.setup() # 每个 module 自己初始化资源 logger.info("module %s started", m.name) def run(self): while self.running: for m in self.modules: try: m.tick() # 单次调度,异常隔离在这一层 except Exception: logger.exception("module %s tick failed", m.name) time.sleep(1) # 调度间隔,按业务调整 def stop(self): self.running = False for m in reversed(self.modules): m.teardown() # 反向关闭,先起的后关 logger.info("module %s stopped", m.name) def handle_signal(signum, frame): logger.info("received signal %s, shutting down", signum) service.stop() if __name__ == "__main__": service = Service() signal.signal(signal.SIGTERM, handle_signal) signal.signal(signal.SIGINT, handle_signal) service.start() service.run()

这段代码里几个点值得说清楚。load_modules()负责扫描 module 目录,把每个模块实例化后返回列表,这样加新功能只要丢一个文件进去,不用改主进程。tick()是每个 module 的单次调度入口,用try/except包住,保证一个模块出错不影响其他模块。signal部分注册了SIGTERM和SIGINT,收到退出信号时走stop(),把每个 module 的teardown()反向执行一遍,这就是优雅退出的基本盘。time.sleep(1)是调度间隔,实际项目里如果模块对实时性要求高,会换成事件驱动或者更短的间隔。

2.2 目录结构与 module 约定

多个 module 的项目,目录结构如果不统一,后面维护会很痛苦。这份资源用的是「一个 module 一个目录、统一入口文件」的约定,结构大致如下:

service/ ├── main_service.py # 主进程入口 ├── config/ │ └── settings.yaml # 全局配置 ├── modules/ │ ├── __init__.py # load_modules 实现 │ ├── collector/ # 采集类 module │ │ ├── __init__.py │ │ └── module.py │ ├── processor/ # 处理类 module │ │ ├── __init__.py │ │ └── module.py │ └── reporter/ # 上报类 module │ ├── __init__.py │ └── module.py └── logs/ └── service.log

每个 module 目录下的module.py必须实现三个方法:setup()、tick()、teardown(),再加一个name属性。load_modules()就是遍历modules/下的子目录,找到module.py并实例化。这种约定的好处是模块之间完全解耦,坏处是每个模块都得写一遍样板代码,所以常见做法是抽一个BaseModule基类,把空实现和日志初始化放进去,子类只覆盖自己关心的方法。

# modules/base.py import logging class BaseModule: name = "base" def __init__(self, config=None): self.config = config or {} self.logger = logging.getLogger(self.name) def setup(self): """初始化资源,如连接、文件句柄""" pass def tick(self): """单次调度逻辑,由主进程循环调用""" pass def teardown(self): """释放资源,保证幂等""" pass

BaseModule把config和logger统一注入,子类直接self.logger.info(...)就能打日志,不用每个模块自己配。teardown()要求幂等,是因为退出流程可能被触发多次,重复释放资源不能报错。这套约定看着简单,但它是整个 service 能稳定跑起来的地基,后面加模块、改模块都靠它。

3. 跑起来:环境准备、启动与验证

3.1 环境与依赖安装

这份资源是纯 Python 实现,没有重依赖,跑起来门槛不高。常见做法是建一个虚拟环境,把依赖装进去,避免污染系统 Python。步骤如下:

# 建虚拟环境 python3 -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 安装依赖,依赖清单一般在 requirements.txt pip install -r requirements.txt

requirements.txt里通常只有pyyaml这类配置解析库,如果 module 里用到了数据库或 HTTP 客户端,会额外加对应的包。装完之后用pip list确认一下,别出现版本冲突。虚拟环境激活后命令行前面会有(venv)提示,看到这个说明环境对了。

3.2 配置与启动

启动前先看一眼config/settings.yaml,里面一般有日志级别、调度间隔、各 module 的开关和参数。配置项按 module 名分组,主进程加载后分发给对应模块。一个典型的配置长这样:

# config/settings.yaml service: interval: 1 # 调度间隔,单位秒 log_level: INFO modules: collector: enabled: true source: "local" processor: enabled: true batch_size: 100 reporter: enabled: false # 上报模块默认关掉,按需开

enabled控制模块是否加载,调试时可以把不相关的模块关掉,减少干扰。interval是主循环的sleep时间,改小响应更快但 CPU 占用更高,改大省资源但实时性差,按业务权衡。启动命令就是直接跑主进程:

python main_service.py

启动后终端会打印每个 module 的启动日志,类似module collector started。如果某个模块setup()里连不上资源,会在这里报错,根据日志定位就行。

3.3 验证进程是否常驻

进程跑起来后,要确认它真的在常驻,而不是启动完就退了。最直接的办法是看进程列表:

ps -ef | grep main_service

能看到进程号说明活着。再进一步,可以看日志文件有没有持续写入:

tail -f logs/service.log

正常情况每秒或每个调度周期会有一行 tick 日志。如果日志停了但进程还在,多半是某个 module 的tick()卡住了,常见原因是里面做了阻塞 IO 没设超时。验证优雅退出可以手动发信号:

kill -TERM <pid>

然后看日志里有没有按顺序打印每个 module 的 stopped,进程是否干净退出。这一步很多人会跳过,结果上线后发现重启时资源没释放,端口被占,又得手动 kill,血泪经验就是退出流程一定要单独测。

4. 核心 module 拆解:采集、处理、上报三段链路

4.1 采集 module:数据从哪来

采集 module 的职责是把外部数据拉进来,放进一个队列或缓冲区,供后续处理。它的tick()通常做一件事:判断当前有没有新数据,有就取一批。下面是一个本地文件采集的简化实现:

# modules/collector/module.py import os from modules.base import BaseModule class CollectorModule(BaseModule): name = "collector" def setup(self): self.source = self.config.get("source", "local") self.cursor = 0 # 记录读取位置,避免重复采集 self.buffer = [] def tick(self): path = self.config.get("path", "data/input.log") if not os.path.exists(path): return with open(path, "r") as f: f.seek(self.cursor) # 从上次位置继续读 for line in f: self.buffer.append(line.strip()) self.cursor = f.tell() # 更新游标 if self.buffer: self.logger.info("collected %d records", len(self.buffer)) def teardown(self): self.buffer.clear()

关键在cursor这个游标,它保证每次只读新增内容,不会把整个文件重读一遍。seek和tell配合使用,是文件采集的标准做法。buffer是模块内部缓冲,处理模块会从这里取数据。实际项目里采集源可能是数据库、消息队列或 HTTP 接口,思路一样:维护一个「读到哪了」的状态,每次只取增量。

4.2 处理 module:数据怎么加工

处理 module 从采集模块的 buffer 里取数据,做清洗、转换或计算,再把结果交给上报模块。模块之间不直接调用,而是通过共享的队列或缓冲区解耦,这样采集慢了不影响处理,处理挂了采集也不停。一个典型的处理实现:

# modules/processor/module.py from modules.base import BaseModule class ProcessorModule(BaseModule): name = "processor" def setup(self): self.batch_size = self.config.get("batch_size", 100) self.output = [] def tick(self): collector = self._find_collector() if not collector or not collector.buffer: return batch = collector.buffer[:self.batch_size] del collector.buffer[:self.batch_size] # 取走并从缓冲移除 for record in batch: processed = self._process(record) if processed: self.output.append(processed) self.logger.info("processed %d records", len(batch)) def _process(self, record): # 具体加工逻辑,按业务替换 if not record: return None return record.upper() def _find_collector(self): # 简化写法,实际通过主进程注入或注册表查找 from modules import registry return registry.get("collector")

batch_size控制单次处理量,太大占内存,太小调度频繁。del collector.buffer[:batch_size]是取走即删除,避免重复处理。_process是业务逻辑的落点,清洗规则、字段映射、计算都在这里改。模块间通过registry查找而不是硬编码引用,是为了让模块可以独立替换,比如把采集源从文件换成数据库,处理模块完全不用动。

4.3 上报 module:结果怎么出去

上报 module 把处理结果发到外部,可能是写文件、发 HTTP 请求或写数据库。它的tick()从处理模块的 output 里取数据,发送成功后清空。上报最容易翻车的地方是网络失败没重试,数据丢了都不知道,所以常见做法是加一个本地暂存,发失败就留着下轮再发:

# modules/reporter/module.py from modules.base import BaseModule class ReporterModule(BaseModule): name = "reporter" def setup(self): self.endpoint = self.config.get("endpoint", "") self.pending = [] # 发送失败暂存 def tick(self): processor = self._find_processor() if processor and processor.output: self.pending.extend(processor.output) processor.output.clear() if not self.pending: return sent = [] for item in self.pending: if self._send(item): sent.append(item) for item in sent: self.pending.remove(item) # 只移除发送成功的 self.logger.info("reported %d, pending %d", len(sent), len(self.pending)) def _send(self, item): # 实际发送逻辑,失败返回 False try: # requests.post(self.endpoint, json=item, timeout=5) return True except Exception: self.logger.warning("send failed, keep in pending") return False def _find_processor(self): from modules import registry return registry.get("processor")

pending列表是失败重试的关键,发送成功才移除,失败留着下轮继续。_send里一定要设超时,不设超时的 HTTP 请求会把整个tick()卡死,进而拖垮整个 service,这是常驻进程里最隐蔽的坑之一。上报模块默认在配置里是关的,调试链路时可以先开采集和处理,确认数据加工对了再开上报。

5. 避坑与排查:常驻进程最容易翻车的几个点

5.1 内存只涨不降

现象是进程跑几小时后内存占用翻倍,重启就好。原因通常是某个 module 的缓冲区只进不出,比如采集模块的buffer一直 append 但处理模块没及时消费,或者上报失败的数据全堆在pending里。解决办法是给缓冲区设上限,超过就丢弃最旧的数据或落盘,同时监控pending长度,超过阈值告警。我一般会在tick()里加一行日志打印各缓冲区长度,出问题时一眼能看出是哪个模块在囤数据。

5.2 日志把磁盘写满

现象是服务跑几天后磁盘告警,一看日志文件几个 G。原因是tick()里打了太多 INFO 日志,每秒一条乘以模块数,量很可观。解决办法是用RotatingFileHandler做日志轮转,按大小或时间切分,保留最近几个文件。配置大概是这样:

from logging.handlers import RotatingFileHandler handler = RotatingFileHandler( "logs/service.log", maxBytes=10 * 1024 * 1024, # 单文件 10MB backupCount=5 # 保留 5 个备份 )

maxBytes和backupCount按磁盘空间调,别设太大。另外把高频的 tick 日志降到 DEBUG,生产环境只留 INFO 以上的关键事件。

5.3 单个模块卡死拖垮全局

现象是日志突然停了,进程还在但什么都不干。原因是某个 module 的tick()里做了阻塞操作,比如没设超时的网络请求、等锁、读大文件。主循环是单线程顺序调度的,一个模块卡住后面全等着。解决办法有两个方向:一是给所有 IO 操作设超时,二是把耗时模块放到独立线程或进程里跑。简单项目用超时就够了,复杂项目建议每个 module 独立线程,主进程只做调度和监控。

5.4 退出时资源没释放

现象是重启服务时报端口被占、文件被锁、数据库连接数没降。原因是teardown()没实现或实现得不对,进程被 kill 时资源没回收。解决办法是确保每个 module 的teardown()都释放自己申请的资源,并且注册信号处理,收到SIGTERM时走完整退出流程。测试方法就是启动后kill -TERM,看日志有没有按顺序关闭,再启动一次看是否正常。这个流程我每次改完 module 都会走一遍,不然上线重启就是一场灾难。

5.5 配置改了不生效

现象是改了settings.yaml重启服务,行为没变。原因通常是配置加载路径写死、或者模块在setup()里缓存了配置但没重新读。解决办法是配置加载统一走一个入口,启动时打印实际生效的配置,方便核对。另外注意 YAML 的缩进,缩进错了解析出来是空字典,模块拿不到配置就用默认值,表现就是「改了没用」。

6. 二次开发:加一个新 module 和改造调度策略

加新 module 是这份资源最常见的二次开发场景。流程不复杂:在modules/下建目录,写module.py继承BaseModule,实现setup/tick/teardown,然后在配置里加一段。假设要加一个定时清理临时文件的 module:

# modules/cleaner/module.py import os import time from modules.base import BaseModule class CleanerModule(BaseModule): name = "cleaner" def setup(self): self.interval = self.config.get("interval", 3600) # 清理间隔 self.last_run = 0 def tick(self): now = time.time() if now - self.last_run < self.interval: return # 没到间隔就跳过 self.last_run = now tmp_dir = self.config.get("tmp_dir", "/tmp/service") removed = 0 for name in os.listdir(tmp_dir): path = os.path.join(tmp_dir, name) if os.path.isfile(path) and now - os.path.getmtime(path) > self.interval: os.remove(path) removed += 1 self.logger.info("cleaned %d files", removed) def teardown(self): pass

这个 module 用last_run做节流,不是每个 tick 都执行,适合低频任务。配置里加:

modules: cleaner: enabled: true interval: 3600 tmp_dir: "/tmp/service"

加完之后重启服务,看日志有没有module cleaner started,再等一个周期看有没有清理日志。验证新 module 有个技巧:先把interval调小到几秒,确认逻辑对了再改回正常值,不然等一小时才看到结果,调试效率太低。

改造调度策略是另一个方向。默认是单线程顺序调度,如果某个 module 耗时明显,可以把它改成独立线程:

import threading class Service: def start(self): for m in self.modules: m.setup() if getattr(m, "threaded", False): t = threading.Thread(target=self._run_module, args=(m,), daemon=True) t.start() self.threads.append(t) def _run_module(self, m): while self.running: try: m.tick() except Exception: logger.exception("module %s tick failed", m.name) time.sleep(m.interval if hasattr(m, "interval") else 1)

给 module 加一个threaded = True属性,它就会跑在独立线程里,主循环不再等它。这样耗时模块不会拖累其他模块,代价是模块间共享数据要加锁。我一般只在确认某个模块是瓶颈时才这么改,过早多线程化会让调试变复杂。改完之后重点验证两点:一是线程退出时teardown()有没有被调用,二是共享缓冲区的读写有没有竞态。从那以后我每次给 service 加线程或加模块,都会先把退出流程和缓冲区长度日志走一遍,确认没有资源泄漏再往下写。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询