
简介压缩包提供了一套面向安卓开发者的进程常驻探索工程含csdnactivity与servicelive1两个模块分别演示不同保活方案覆盖AIDL跨进程通信、前台服务、进程优先级调整等关键技术。资源共69个文件其中Java源码20个、XML配置18个、PNG说明图20张、AIDL接口2个辅以Gradle构建脚本与jar依赖总大小16.9MB便于导入IDE直接编译运行。目前已有319人学习下载。通过对照两个模块的实现差异既可理解守护进程、服务管理器等概念在安卓中的落地方式也可掌握利用系统API和nice值调整进程优先级的思路并了解进程生命周期管理与系统资源限制的应对方法。资源还探讨了进程保活的边界问题如优先级过高对其他进程的影响、常驻服务的内存占用与释放策略以及结合日志监控、自动重启机制增强稳定性配合截图与工程目录能快速定位关键代码并二次开发适合对应用保活与系统服务管理感兴趣的Android开发进阶者。1. 一个常驻进程里塞进多个 module架构红利还是定时炸弹做后台服务经常会遇到这种场景一台配置不高的机器上采集、清洗、告警几个模块各自跑成一个进程资源翻倍占用上线时还得分别部署、分别盯日志。于是很多人开始尝试把这些模块收敛进同一个常驻进程共享配置、共享连接池、共享生命周期。这个「进程常驻 多个 module」的组合听起来只是改了下启动方式真做起来会发现进程边界、模块隔离、退出信号、内存回收这些老问题全被重新点燃。这篇文章会讲清楚怎么设计一个多模块常驻进程的骨架怎么用 supervisor 和 systemd 把它托管起来做到异常自愈以及这套结构里最容易被忽视的五个坑。无论你是在重构后台脚本还是准备把零散任务收拢成常驻服务都值得往下看。2. 多模块常驻进程的核心设计进程边界、生命周期与模块间通信在动手写代码之前先想清楚三件事模块为什么放进一个进程、模块的出生和死亡顺序怎么管、模块之间怎么说话。很多翻车现场都出在这三个问题上。2.1 为什么多个模块要挤在一个进程里省资源与省心以及要付的代价先看多进程方案。每个模块一个进程隔离确实干净一个崩了不影响另一个但代价是运行时开销各来一套。以 Python 为例一个空进程基础内存 30~50M三个模块就是 120M 打底还不算每个进程独立占用的日志句柄、定时器资源、socket 连接。而单进程多模块所有模块共享同一个 Python 解释器、共享同一个 event loop内存和句柄都省了一半以上。再考虑共享成本的差异。三个模块做同一件事的上下游采集模块往队列丢数据清洗模块消费告警模块感知异常。拆成多进程之后数据要过消息队列配置要独立加载或走远端连接池每个进程各建一套排查问题时还要跨进程追日志。塞进一个进程这些全部变成进程内引用调试体验直接上一个台阶。下表把这笔账列清楚对比维度多进程部署单进程多模块内存与句柄每个进程独立一份共享一份运行时配置与连接共享靠网络或重复初始化进程内直接引用故障隔离进程间天然隔离需要模块级隔离兜底部署复杂度每模块一套托管配置只托管一个进程那么什么时候不该做单进程多模块我的判断标准是如果这些模块之间的关联只是在同一台机器上而不是业务链路强依赖那就不要硬塞。比如「用户画像」和「日志收集」共用一台机器但毫无数据往来硬放一起只是省了一台机器出了故障影响范围反而变大。注意合并模块的动机必须是「共享」和「协作」不是「省部署」。为了省一次上线操作把无关模块绑在一起后续每次变更都会相互踩脚。2.2 模块生命周期启动顺序、依赖声明与退出信号多个模块在一个进程里第一个要解决的问题是启动顺序。常见做法是把每个模块的启动收敛成一个 start(ctx) 方法让模块自己声明依赖谁然后由注册中心做拓扑排序而不是靠注册顺序猜。from abc import ABC, abstractmethod class Module(ABC): 常驻模块基类。dependencies 声明启动依赖用于拓扑排序。 dependencies: list[str] [] def __init__(self, name: str): self.name name abstractmethod def start(self, ctx): 启动模块。ctx 是进程级共享上下文。 abstractmethod def stop(self): 停止模块释放线程、连接、临时文件。这段代码定义了模块的公共契约。dependencies 是一个字符串列表表示本模块启动前必须已启动的模块名。start 拿到 ctx 后可以做初始化stop 只负责反着收尾。契约越简单后面接进来的模块越不容易写错。注册中心的拓扑排序我一般这样写def resolve_order(modules: dict) - list[str]: visited: set[str] set() result: list[str] [] def visit(name: str, chain: list[str]): if name in chain: raise RuntimeError(fmodule dependency cycle: {chain [name]}) if name in visited: return visited.add(name) for dep in modules[name].dependencies: visit(dep, chain [name]) result.append(name) for name in modules: visit(name, []) return result这段代码的逻辑是深度优先遍历加回溯检测。chain 保存当前递归路径一旦发现某个模块又出现在链上就说明存在依赖环直接抛错。环检测必须在启动前完成不然两个模块互相等对方初始化运行到一半才暴露排查成本高得多。退出信号同样是一等公民。进程收到 SIGTERM 时不应当直接 os._exit(0)而要先按启动顺序的反向调用每个模块的 stop()再关闭共享连接。原因是采集模块可能正在写状态文件清洗模块的队列里还有未处理数据强行退出会让这些操作停在半路。2.3 模块间通信三选一函数调用、事件总线与共享队列进程内多模块通信我的建议是能不含糊就不含糊先按耦合度从低到高排三个方案。直接函数调用最简单采集模块直接调清洗模块的清洗接口。问题在于调用方要 import 被调方的模块一旦两个模块编译期就绑死改接口签名就要连带改动相当于把两个模块变成一个大模块。两三个模块内部玩可以模块数量上来就劝你换事件。事件总线适合解耦「通知」类通信。发送方只管 emit 一个事件不关心谁订阅接收方只需 on() 注册处理函数。我一般把事件总线做成一个瘦类from collections import defaultdict class EventBus: 进程内事件总线模块之间通过事件名解耦。 def __init__(self): self._handlers: dict[str, list[callable]] defaultdict(list) def on(self, event: str, handler): self._handlers[event].append(handler) def emit(self, event: str, payloadNone): for handler in list(self._handlers[event]): handler(payload)list() 拷贝这一步是为了防止在遍历过程中有模块动态增删 handler 导致运行时错误。事件总线的代价是链路变暗出现了 bug 很难靠读代码直接定位「谁调的谁」所以要配合日志把 emit 和 on 都打点。共享队列用于生产消费模式比如采集模块不停入队清洗模块批量取出。Python 里用 queue.Queue 时一定要设置 maxsize并且 put 时带 timeout否则消费者跟不上生产者会一直堆积最后把内存吃满。选型上我的经验是3 个模块以下直接用函数调用加事件总线模块 4 个以上、链路经常变化上事件总线加共享队列别一到多模块就往进程里塞消息中间件进程内通信走网络协议属于自我增加故障点。3. 从零搭一个多模块常驻进程Python 骨架与 supervisor 自愈部署这一章给你一套能跑的最小骨架。骨架覆盖三个点模块抽象、注册中心、带优雅退出的主循环。把它落地到一台服务器上再交给 supervisor 托管你就拥有了一个可以自愈的多模块常驻进程。3.1 一个能跑的 Python 骨架模块抽象、注册中心与主循环main.py 全貌为了可读性省略具体业务实现# main.py import signal import time import logging from abc import ABC, abstractmethod logging.basicConfig( levellogging.INFO, format%(asctime)s %(levelname)s %(name)s %(message)s, ) logger logging.getLogger(main) class Module(ABC): dependencies: list[str] [] def __init__(self, name: str): self.name name abstractmethod def start(self, ctx: Context) - None: 启动模块ctx 是进程级共享上下文。 abstractmethod def stop(self) - None: 停止模块释放线程、连接、临时文件。 def health(self) - bool: return True class Context: 进程级共享上下文配置、队列、连接池统一放这里。 def __init__(self, config: dict): self.config config self.queues {} class Registry: 模块注册与依赖排序。 def __init__(self): self._modules {} def register(self, module: Module) - None: if module.name in self._modules: raise KeyError(fduplicate module: {module.name}) self._modules[module.name] module def resolve(self) - list[str]: visited, result set(), [] def visit(name, chain): if name in chain: raise RuntimeError(fmodule dependency cycle: {chain [name]}) if name in visited: return visited.add(name) for dep in self._modules[name].dependencies: visit(dep, chain [name]) result.append(name) for name in self._modules: visit(name, []) return result class Collector(Module): dependencies [] def start(self, ctx: Context) - None: logger.info(collector started with batch%s, ctx.config.get(batch)) def stop(self) - None: logger.info(collector stopped) def main(): config {batch: 100} ctx Context(config) registry Registry() registry.register(Collector(collector)) # 在这里继续 register 其他模块 for name in registry.resolve(): registry._modules[name].start(ctx) def _shutdown(signum, frame): logger.info(received signal %s, stop modules in reverse order, signum) for name in reversed(registry.resolve()): registry._modules[name].stop() raise SystemExit(0) signal.signal(signal.SIGTERM, _shutdown) signal.signal(signal.SIGINT, _shutdown) while True: try: for mod in registry._modules.values(): if not mod.health(): logger.warning(module %s is unhealthy, mod.name) time.sleep(1) except Exception: logger.exception(main loop caught error, keep running) if __name__ __main__: main()代码逻辑可以拆成三块看。第一块是 Module 和 Context。Module 把每个模块的启动和停止抽象成 start/stopContext 专门放共享对象避免各模块用全局变量互相污染。Collector 这种模块只实现必要方法业务逻辑可以丢到 start 里起的线程中也可以在主循环里轮询取决于模块是事件驱动还是定时任务。第二块是 Registry 的 resolve。它按 dependencies 做拓扑排序并在有环时直接抛异常。这样新增模块时不需要手动调启动顺序删掉某个模块也不会波及其他人。第三块是主循环。它的职责是维持进程活着并按秒轮询每个模块的 health()。signal.signal 把 SIGTERM 和 CtrlC 的 SIGINT 都绑定到同一个 shutdown 函数退出时按启动顺序的反向 stop 所有模块最后 raise SystemExit(0)。运行时我建议把 time.sleep(1) 作为一个心跳周期。sleep 时间太长会拖慢 signal 响应太短又空耗 CPU1 秒是常驻进程常见的折中值。真正跑批量的模块应该自己开线程或协程不要把耗时操作放在这个主循环里否则 health 检查会被卡住。3.2 用 supervisor 托管异常自愈与信号传递代码写好后部署方式一般有两种systemd 或 supervisor。这一节先说 supervisor因为它的重启策略和日志管理对常驻进程比较直接。先安装并准备目录sudo apt-get update sudo apt-get install -y supervisor sudo mkdir -p /opt/myapp/logs然后把下面内容写入 /etc/supervisor/conf.d/myapp.conf[program:myapp] command/usr/bin/python3 /opt/myapp/main.py directory/opt/myapp userwork numprocs1 autostarttrue autorestarttrue startsecs3 startretries3 stopsignalTERM stopasgrouptrue killasgrouptrue stdout_logfile/opt/myapp/logs/stdout.log stderr_logfile/opt/myapp/logs/stderr.log stdout_logfile_maxbytes50MB stdout_logfile_backups5参数里最关键的是 autorestart、startsecs、stopasgroup 和 killasgroup 这四个。autorestarttrue 让进程非正常退出后自动拉起startsecs3 表示进程存活 3 秒后 supervisor 才认为这次启动成功避免启动即崩溃的情况下无限重启。stopsignalTERM 对应骨架代码里处理的 SIGTERM。stopasgroup 和 killasgroup 的作用是让信号覆盖整个进程组防止模块自己 fork 出来的子进程不接收退出信号。配置写好后加载sudo supervisorctl reread sudo supervisorctl update sudo supervisorctl status myapp看到 RUNNING 就说明托管成功。可以顺手做一次崩溃自愈的验证直接 kill 掉进程确认几秒后 supervisor 会把新进程拉起来。日志切分交给 stdout_logfile_maxbytes 和 stdout_logfile_backups分别控制单文件大小和保留份数这是常驻进程最容易忽略的磁盘隐患。3.3 目录结构与日志约定部署前先想清楚常驻进程的目录结构我一般按四个目录分隔主程序放 app/模块代码放 modules/配置文件放 config/运行日志放 logs/。不让代码目录和日志目录混在一起。模块加载上有两种常见做法。骨架里用的是静态注册在 main() 里显式 register。它的优点是可读性好、依赖关系清晰适合模块数量少、团队小的项目。但如果模块数量多到十几个或者希望上线新模块不动主进程就要用动态扫描让 modules/ 下的每个文件注册自己主进程启动时遍历目录按约定自动加载。动态扫描的坑是隐式依赖模块之间如果相互 import注册顺序就失去意义所以动态加载要和依赖声明配合使用。日志约定同样值得提前定好。每个模块的 logger 命名必须以模块名开头例如 logger logging.getLogger(collector)日志级别默认 INFO关键状态变更必须打点。这样排查问题时可以用 grep collector 快速过滤出一个模块的完整活动轨迹不用在混在一起的日志里大海捞针。4. 交给 systemd 托管开机自启、异常拉起与资源限制如果说 supervisor 是部署常驻进程的百宝箱那 systemd 就是 Linux 环境下更原生的托管方案。很多云主机默认就有 systemd不用额外装东西还能开机自启和内核的 cgroup 联动做资源限制。systemd 托管常驻进程要做的配置、验证和资源护栏下面分开说。4.1 一个可用的 unit 文件Typesimple 与 ExecStart在 /etc/systemd/system/myapp.service 写[Unit] DescriptionMulti Module Resident Process Afternetwork-online.target Wantsnetwork-online.target [Service] Typesimple Userwork WorkingDirectory/opt/myapp ExecStart/usr/bin/python3 /opt/myapp/main.py Restarton-failure RestartSec5 TimeoutStopSec20 EnvironmentPYTHONUNBUFFERED1 StandardOutputjournal StandardErrorjournal [Install] WantedBymulti-user.targetTypesimple 表示 ExecStart 启动的进程就是服务主进程前台运行这正好匹配我们的 main.py。很多从 supervisor 迁过来的人习惯写ExecStart/bin/bash -c python3 main.py 这是最常见的错误把前台进程变后台systemd 无法追踪主 PID后面 Restart、KillMode 全部失效。Restarton-failure 的设计意图是只在非正常退出时拉起。如果模块代码自己调用了 sys.exit(0)systemd 会认为这是正常结束不重启。这对常驻进程来说很重要你希望它「死了就拉」但又希望「我不想让它跑的时候手动 stop 不会被立刻拉起来」。RestartSec5 控制两次重启间隔TimeoutStopSec20 是优雅退出的最大宽限systemd 先发 SIGTERM20 秒后进程还在就补 SIGKILL。骨架里的 stop() 如果能在几秒内完成20 秒足够手工核对日志。启用sudo systemctl daemon-reload sudo systemctl enable --now myapp.service systemctl status myapp.service --no-pager4.2 用 kill -9 验证自愈理解 Restart 与正常退出的区别部署完第一件事是验证自愈而不是急着写业务。验证命令如下pgrep -f python3 /opt/myapp/main.py # 拿到主进程 PID kill -9 PID # 模拟模块灾难性崩溃 sleep 8 systemctl status myapp.service --no-pager8 秒后如果状态是 active (running) 且 Main PID 变成了新值说明 Restart 生效。这里有一个常见的理解偏差kill -9 会直接杀掉进程不走我们代码里的 SIGTERM 处理模块的 stop() 全被跳过因此这次重启本质是「非优雅重启」。用 kill -9 测的是兜底能力不能用它验证优雅退出。要验证优雅退出改用kill PID然后立刻看日志journalctl -u myapp.service --since 1 minute ago --no-pager应该能看到骨架里的「received signal 15, stop modules in reverse order」以及每个模块的 stop 日志。如果只打了 received 没有模块的 stop 日志说明模块的 stop() 里阻塞了要去查是不是有线程 join 没有超时。systemd 对「认为进程退出」还有一个细节如果主进程 fork 出去的子进程还在跑ExecStart 的进程已经死了systemd 也能检测到并触发 Restart但子进程可能残留成孤儿继续占用文件句柄。所以多线程常驻进程要在 unit 里加KillModemixed让 systemd 在停止时先把信号发给主进程超时后再杀剩余进程。4.3 加装资源护栏内存、句柄与线程数限制资源限制是 systemd 托管常驻进程最值钱的部分。很多模块的内存泄漏不是立即爆发的而是跑了两周之后才暴露到那时候现场已经被 OOM kill 清掉了想抓证据都难。同样加到 [Service] 段[Service] LimitNOFILE1048576 MemoryMax512M TasksMax512MemoryMax512M 是硬限制进程及其子进程总内存超过 512M 后会被内核 OOM kill。别小看这个值采集模块里一个不经意的列表累积就能把内存从 200M 涨到 2G有了这个限制进程会在内存失控前被杀死并被 Restarton-failure 拉起而不是等整台机器被拖垮。LimitNOFILE 控制文件描述符上限。常驻模块若频繁打开 socket 或文件又没释放会先耗尽默认的 1024 句柄表现为「连接失败、日志报 EMFILE」这个值调大后再观察是否还会增长。TasksMax 限制线程数防止某一模块失控无限创建线程把 CPU 拖死。设置完成后重载并验证sudo systemctl daemon-reload sudo systemctl restart myapp.service sudo systemctl show myapp.service -p LimitNOFILE,MemoryMax,TasksMax cat /sys/fs/cgroup/myapp.scope/pids.current # 实际线程数再做一个内存超限实验临时把 MemoryMax 调到 64M启动一个吃内存的模块观察 systemd 是否把进程拉起来。实验完记得把 MemoryMax 调回正常值。这套验证做完资源这块才算真正落地而不是拍了配置就当完事。5. 多模块常驻进程避坑记录5 个高频事故与排查思路多模块常驻进程的坑多数不是「代码写不出来」而是「问题出在边界上」。下面这五条是我自己和其他人踩过最多的地方按现象、原因、解决三步写方便你对号入座。5.1 现象一个模块抛异常整个进程退出本意是采集模块遇到脏数据报一个 ValueError结果整个常驻进程直接消失。原因通常是主循环里调用模块方法时没有 try/except或者 start() 阶段抛出的异常没有被注册中心捕获异常一路传到顶层进程就崩溃。多模块进程如果没有模块级隔离任何一个模块的运行时异常都会成为全体模块的死亡通告。解决分两层。第一层是主循环对所有模块调用包一层 try/except异常打日志而不是让进程退出第二层是连续异常次数达到阈值后把该模块标记为 unhealthy触发降级比如跳过该模块的消费逻辑同时告警。变化后的主循环伪代码如下failure_count {} for name, mod in registry._modules.items(): try: mod.health() except Exception as exc: failure_count[name] failure_count.get(name, 0) 1 logger.exception(module %s health check failed: %s, name, exc) if failure_count[name] 5: logger.error(module %s has too many failures, degrade it, name) # 这里执行降级动作例如停止接受新任务这段代码展示了「一次异常不退进程连续异常才降级」的思路。五个失败阈值是经验值具体按模块特征调短任务可以多点几次长任务阈值要放宽。5.2 现象内存只涨不降跑到 OOM 才被发现这是一个典型的滑行事故它不突然而是缓慢。原因千奇百怪最常见的三类模块里用全局 list 或 dict 当缓存、logger 句柄未释放、数据库查询结果集一次性全部加载到内存。常驻进程的优势是常年不重启代价正是任何一点内存泄漏都会被时间放大。等到 systemd 的 MemoryMax 或系统 OOM killer 动手现场已经被清理干净你只能靠监控曲线回忆案发过程。解决思路是给常驻进程加内存观测点。最简单的做法是用 psutil 每分钟记录一次 RSSimport psutil rss psutil.Process().memory_info().rss / 1024 / 1024 logger.info(rss%.1f MB, rss)把这个循环放进主循环每 60 秒打一条内存曲线就有了。然后再谈恢复缓存加 maxsize 用 FIFO 或 LRU 淘汰数据库查询分批取连接用完必须归还池。血泪经验是不要相信模块作者说「这里不会积累数据」常驻进程里一切没有上限的集合都是定时炸弹。5.3 现象进程重启后模块状态错乱数据重复消费常驻进程重启一次第二天发现清洗模块把同一批数据重复写了两遍。原因一般不是主程序的问题而是模块把「处理到哪」「哪些消息已处理」这样的状态只存在内存里没有持久化。重启后从零开始自然把所有数据当成新的。消息队列重投、文件行数偏移、定时任务补偿都会放大这个影响。解决的核心是找出必须跨重启保留的状态把它持久化。简单方案是本地文件def save_offset(path: str, offset: int): tmp f{path}.tmp with open(tmp, w) as f: f.write(str(offset)) os.rename(tmp, path) # 先写临时文件再原子替换先写临时文件再 rename避免进程在写入中间被 kill 留下半行数据。更稳但复杂的是把状态落到数据库。我的建议凡是「丢了会出错」的状态都持久化凡是「丢了只是重新跑一遍」的状态可以留在内存二者要在一开始就分类清楚。5.4 现象模块启动顺序永远不对报「配置还没初始化」代码里模块注册顺序是 A、B、C日志里某个配置项一直是 None程序在启动阶段就开始调用一个尚未初始化的配置模块。原因是没有依赖声明大家都在赌启动顺序。今天能用是因为碰巧注册顺序对明天有人改了一下注册顺序就全线翻车。解决方法是回到 2.2 的依赖声明让每个模块把依赖写到类属性里由注册中心拓扑排序。配套纪律是任何模块不允许在 import 阶段访问其他模块的全局状态一定要把访问延迟到 start(ctx) 之后因为 import 时依赖的模块可能还没加载。这个纪律能避免一大批「玄学启动问题」。代码上如果发现模块在模块级直接读取共享配置review 时就要打回去。5.5 现象退出日志显示 stop 没走完子进程还占着端口现象是 supervisor 显示进程已停止但端口还被占用或者日志里只有「received signal」没有模块 stop 完成的记录。原因多出在两个地方一是信号处理函数被多次注册后注册的覆盖了前面的二是 stop() 卡在某个线程 join 没有超时信号处理永远跑不完。解决分两步。第一步全进程只允许主进程注册 signal.signal模块不接触信号只实现 stop()。第二步stop() 中所有线程 join、socket close、数据库关闭都要带超时防止一方不配合把整个退出流程拖死。我用过的最小有效写法import time def stop(self): self.running False self.worker.join(timeout5) # 等 5 秒还退不出就放弃不阻塞整体退出这里的关键是 timeout5。常驻模块的 worker 如果卡在外部调用或 socket 读不给超时优雅退出就永远等不到头。退出卡住的黑匣子十有八九是这种没有超时保护的收尾代码。6. 打磨常驻进程的最后一公里优雅退出验证与热重载骨架能跑、托管配置好后剩下的都是细节但这些细节决定你晚上敢不敢接这个服务的告警。先说优雅退出的验证清单。我每改一版多模块常驻进程都会按顺序跑三件事kill -TERM确认日志里所有模块的 stop 都完成kill -KILL确认 systemd 或 supervisor 能拉起来再人为往模块里抛一个异常确认主循环继续跑而不是整个进程退出。把这三件事做成一个 shell 脚本放进 CI 或本地 pre-push比口头约定靠谱得多。热重载是我在模块多起来之后才加的打磨点。常驻进程的悲哀是每次改代码都要重启重启一次就丢一轮状态。我后来在骨架里加了一个最基础的热重载定时检查模块文件的 mtime变化后按 stop → reload → start 的顺序更新单个模块。import importlib import os def reload_module(registry, name, ctx): registry._modules[name].stop() path fmodules/{name}.py mtime os.path.getmtime(path) importlib.reload(importlib.import_module(name)) registry._modules[name].start(ctx)注意这个做法只适合无状态或状态可重建的模块。像持有长连接、处理中队列的模块热重载要非常克制必须先把模块的工作线程暂停等队列清空或状态持久化完成再 reload。热重载不是万能药它是让你在「必须重启」和「完全不能重启」之间多一个台阶。最后一个习惯我现在每次上线前都会用 systemd-analyze 检查 unit 配置sudo systemd-analyze verify /etc/systemd/system/myapp.service这一步能提前发现 ExecStart 路径错误、权限问题、Unit 段拼写错误。这套「优雅退出三件套 热重载 上线前 verify」的习惯帮我挡掉了不少线上事故希望帮到你。本文还有配套的精品资源点击获取