关于 shiny-py-optimize
用于诊断和加速现有 Shiny for Python 应用的技能,同时保持应用行为不变。它针对共享 asyncio 事件循环,优先消除或复用计算,再将阻塞任务移出。
主要功能
- 01检查响应式与渲染瓶颈
- 02通过响应式计算减少重复工作
- 03使用缓存和事件门控输入
- 04改进数据加载与表格渲染
- 05测量改动并保持行为一致
适用场景
- 01减少每个会话的数据加载延迟
- 02减少输入变化后的重复计算
- 03避免长时间操作阻塞所有会话
- 04比较代码改动前后的性能
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ posit-dev/ skills --skill shiny-py-optimize安装后在 Claude Code 中输入/shiny-py-optimize确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/async-tasks.mdcaching.mddata-loading.mddiagnosis.mdreactive-graph.mdrendering-ui.md详细介绍
优化 Shiny for Python 应用的性能
在不改变现有 Shiny for Python 应用功能的前提下提升其速度。典型情况是:应用原本供一两位开发者使用时运行良好,现在却变得太慢,或需要支持更多用户。保留所有应用逻辑;优先修改代码,而非调整部署;避免重写。
每项优化的优先顺序:
- 消除不必要的工作(不要计算无人查看的输出;输入尚未就绪时不要进行计算)。
- 复用重复的工作(共享的
@reactive.calc、模块级缓存)。 - 加速不可避免的工作(更快的数据格式、polars、下推)。
- 转移阻塞性工作(使用
@reactive.extended_task)——最后的手段;它带来的是并发能力,而非原始速度。
大多数 Python 专属建议都基于一个事实:**Shiny 会在单个 asyncio 事件循环中串行运行响应式函数——绝不会并发运行,即使函数是 async def,甚至同一进程中的不同会话也是如此。**任何响应式函数中的阻塞调用都会冻结该进程中的所有会话。仅使用 async/await 永远无法解决这个问题;只有 @reactive.extended_task 可以。
工作流程
1. 了解问题
明确“慢”具体指什么,并将其设定为要超越的基准:
- **哪些操作感觉很慢?**首次加载页面?每次交互?某个特定操作?只有多人同时使用时才会慢?
- 需要支持多少并发用户?(1–5 人、约 20 人、数百人?)
- **应用运行在哪里?**本地、Posit Connect、Posit Connect Cloud,还是自托管(在 nginx 后运行 uvicorn)?
- 数据有多大、存放在哪里,以及多久更新一次?
- Core 还是 Express?(
from shiny import ...与from shiny.express import ...)。Express 顶层代码会在每个会话中运行一次——这是一个常见且不易察觉的陷阱(data-loading.md)。
2. 扫描代码中的异味,并立即修复
阅读应用代码(app.py、各模块),查找常见的代码异味。此表中的每种异味都有安全且不改变行为的修复方法:在测量之前,立即应用找到的每项修复。这些改动能解决绝大多数运行缓慢的 Shiny for Python 应用问题。
| 异味(搜索内容) | 影响 | 修复方法 |
|---|---|---|
pd.read_csv/数据库查询位于 server() 或渲染函数内部 | 每个会话或每次失效时都会重新运行 | 每个进程只加载一次:Core 放在模块作用域;Express 放在导入的模块中 → data-loading.md |
Express app.py 顶层加载数据 | Express 顶层代码会为每个会话运行一次 | 将加载操作移到导入的模块(shared.py)中 → data-loading.md |
| 多个输出中重复进行相同的子集筛选/聚合 | 每次变化都会重复计算 | 提取一个共享的 @reactive.calc → reactive-graph.md |
耗时的 @render.* 没有 req() 保护 | 启动时会使用空输入或尚未就绪的输入进行计算 | 在函数体开头调用 req() → reactive-graph.md |
| 落地视图上有许多耗时的输出 | 页面可用前必须完成所有计算 | 移到标签页/导航中——隐藏的输出会暂停运行 → rendering-ui.md |
| 多个会话中重复执行相同的耗时操作 | 每位用户都要重复付出相同的计算成本 | 使用模块级缓存(functools.lru_cache、diskcache)——没有内置的 bindCache 等效功能 → caching.md |
| 滑块/文本输入直接触发耗时的计算链 | 拖动一次就会导致数十次重新计算 | 用 @reactive.event() 按钮控制;使用防抖辅助函数 → reactive-graph.md |
在计算/副作用/渲染中内联调用 time.sleep、同步 requests、耗时的 pandas/DuckDB 调用 | 会阻塞进程中所有用户的事件循环 | 使用 @reactive.extended_task → async-tasks.md |
添加 async def 响应式函数,期望获得加速 | 即使使用异步代码,Shiny 仍会串行运行响应式代码 | 仅对仅支持异步的 API 使用异步;使用 ExtendedTask 实现非阻塞 → async-tasks.md |
将 @render.ui 用于常规值更新 | 每次更新都会重建 DOM 并重新绑定输入 | 使用 ui.update_*() → rendering-ui.md |
对大型数据框使用 @render.table | pandas Styler 会将数据完整序列化为 HTML | 使用 @render.data_frame 和 render.DataGrid → rendering-ui.md |
按以下大致顺序应用修复(从成本最低的开始):将数据加载移到模块作用域(Express 则移到导入的模块中)→ 添加 req() 保护 → 使用共享的 @reactive.calc → 将耗时输出移到标签页 → 使用模块级缓存 → 使用 @reactive.event() 控制频繁触发的输入 → 使用 ui.update_*() 替代 @render.ui → 使用更快的数据格式(polars、parquet、DuckDB)→ 对耗时数秒的操作使用 @reactive.extended_task → 为剩余的等待添加忙碌指示器(ui.busy_indicators)。
3. 进行测量——用于确认,而非设限
测量用于确认修复效果,并发现扫描无法发现的问题。**绝不能让测量挤占实际行动。**如果应用难以运行、追踪耗时过长,或结果含糊不清,仍应应用扫描发现的修复,并说明哪些内容无法验证。
Shiny(≥ 1.6)内置了用于追踪响应式执行的 OpenTelemetry——这是诊断“哪些代码运行缓慢”的主要工具:
uv pip install "shiny[otel]"
SHINY_OTEL_COLLECT=reactivity \
opentelemetry-instrument --traces_exporter console --logs_exporter console \
--metrics_exporter none shiny run app.py
与应用交互,然后查看跨度层级:每个运行缓慢的 reactive.calc/输出/扩展任务都会显示其源文件和行号。需要查看 CPU 火焰图时,可用 py-spy 对运行中的进程采样;用 timeit 比较两种实现。要了解“此应用可支持多少用户”,请使用 shinyloadtest 对已部署的应用进行负载测试(录制 → 使用 N 个工作进程回放 → 生成报告)。工具选择与阅读指南:diagnosis.md。
4. 诊断扫描未发现的问题
| 症状 | 最可能的原因 | 从这里开始 |
|---|---|---|
| 首次加载页面很慢(每个会话都如此) | 按会话加载数据(尤其是 Express 顶层代码);启动时用空输入计算输出;繁重的会话初始化 | data-loading.md、reactive-graph.md |
| 一次交互触发了过多重新计算 | 依赖范围过宽;重复计算;输入更新频繁 | reactive-graph.md、caching.md |
| 某个操作耗时数秒,且应用冻结——影响所有人 | 在共享事件循环中执行了阻塞操作 | async-tasks.md |
| 单个用户使用时很快,用户增多后性能下降 | 阻塞操作;重复进行相同计算;每用户重新加载;进程数过少 | caching.md、async-tasks.md、diagnosis.md |
| 图表/表格显示或更新缓慢 | 渲染开销大;使用 @render.table;完整重绘;Plotly 数据未经聚合 | rendering-ui.md |
| 下载/上传行为异常或触发重新计算 | 下载处理程序每次点击都重新计算;在渲染器中解析上传文件 | data-loading.md |
| 仅在部署后变慢 | 按进程隔离的全局变量和缓存;进程/连接数限制 | diagnosis.md |
5. 修复,先从成本最低的改动开始
针对已诊断的问题类别,按阶梯逐步处理;测量结果表明速度足够时即可停止。每一步都能保留应用行为。
响应式重新计算 → reactive-graph.md
- 使用
req()守卫——输入未就绪时不进行计算。 - 使用共享的
@reactive.calc——每次变更只计算一次派生值。 - 缩小依赖范围:用于更新循环的
@reactive.event()、reactive.isolate()、reactive.value.freeze()。 - 对频繁更新的输入进行防抖(辅助工具——截至 Shiny 1.8 尚未内置);当更新会累积时,用
ui.input_task_button()控制整批更新(可直接替换ui.input_action_button())。 - 修正定时器:将
invalidate_later()放在最后安排,或使用带有廉价检查函数的@reactive.poll()。
重复进行相同的工作 → caching.md
@reactive.calc会为每个会话缓存最新值——无需额外操作。- 在模块级纯函数(数据库查询、模型拟合)上使用
@functools.lru_cache——键必须涵盖影响结果的所有输入。 - 当结果需要在进程间共享、在重启后保留,或数据会随时间变化时,使用
diskcache/cachetools.TTLCache。 - 将缓存结果视为只读共享状态——修改前先返回副本。
阻塞操作 → async-tasks.md
- 只有在已尝试消除和缓存之后,才使用
@reactive.extended_task,搭配ui.input_task_button+@ui.bind_task_button。 - 任务中的同步 I/O →
asyncio.to_thread(...);CPU 密集型工作 → 模块级ProcessPoolExecutor(普通线程对 CPU 密集型工作无济于事)。 - 任务主体内不要读取响应式值;通过
.invoke()传入值。
数据访问 → data-loading.md
- 在进程作用域加载数据(Core 中为模块作用域;Express 中为导入的模块);切勿在渲染函数中读取数据。
- 对较大的数据使用 polars 而非 pandas;使用 parquet 而非 CSV。
- 数据超出 RAM 容量,或只需使用部分数据时,使用 DuckDB 读取 parquet,或采用惰性执行
pl.scan_parquet(...).filter(...).collect()。
渲染与 UI → rendering-ui.md
- 将开销大的输出放在标签页/导航区域中,使隐藏的输出保持挂起状态。
- 使用按钮控制可能提前计算的输出(
req()+@reactive.event())。 - 表格使用
@render.data_frame+update_data();大型 Plotly 图表使用 WebGL/聚合;运行期间显示忙碌指示器。
6. 验证并迭代
每次只做一项更改后重新测量,并与基准数据进行比较。保持可观察到的行为完全一致——删掉某个功能的应用并不算“更快”。完成前,从原始应用和修改后的应用(或原始数据)中重新计算一个有代表性的输出值,并确认两者一致——这能以较低成本发现无声的行为偏移。
报告完整性:将所有引用的测量数据(跟踪输出、计时日志)保存到应用项目中的 outputs/measurements/ 下;如果文件夹不存在,就创建它;并根据保存的产物核对最终报告中声称的每个数字和机制。如果某项结论无法通过产物验证,就在报告中说明,而不是直接断言。
<app-project>/
outputs/
measurements/ # the audit trail for the final report: one file per
# measurement (trace output, py-spy profile, timings),
# named for the fix or code path it measured
基本原则
- 明显的问题批量处理;不确定的问题单独处理。 对效果可预测的安全修复——将数据加载移到模块作用域、合并重复的筛选逻辑、替换
@render.ui——可以一并应用,然后重新测量。只有在确实不清楚哪项更改起了作用,或某项更改未达到预期效果时,才一次只改一项;只有在这种情况下,额外的测量轮次才值得投入。 - 保持行为不变。 UI 更改仅限于改善感知性能的辅助功能(加载指示器、任务按钮、标签页),并且必须告知用户;同样,改变时序语义的更改也必须告知用户,即使输出完全相同(防抖会有意增加延迟;缓存会跨会话共享结果)。
async不是性能修复方案。async def响应式函数的存在是为了调用仅支持 async 的 API。它们不会并发运行,也不会让应用保持响应。非阻塞意味着使用@reactive.extended_task。- 先缓存,再考虑 async。 缓存通常能带来 10–100 倍的提升;async 只增加并发能力,还会带来序列化和调试成本。
- 绝不要通过多工作进程服务扩展。
uvicorn --workers >1、WEB_CONCURRENCY环境变量以及多工作进程 Gunicorn 都会无声地破坏 Shiny(需要粘性会话,diagnosis.md)。应通过粘性会话负载均衡,在多个单工作进程后扩展。 - 添加依赖项前先征求同意(polars、duckdb、diskcache、py-spy……)。
参考资料
仅在需要时阅读诊断结果所指向的参考资料——不要一开始就全部阅读。
- diagnosis.md — OpenTelemetry 跟踪、py-spy、计时检查、使用 shinyloadtest 进行负载测试;单进程 asyncio 模型;粘性会话和部署容量调节参数。
- reactive-graph.md — 共享计算、
req()、reactive.isolate()、@reactive.event()、更新循环和reactive.value.freeze()、防抖辅助函数、计时器和轮询。 - caching.md —
@reactive.calc会缓存和不会缓存的内容;模块级lru_cache/diskcache/cachetools模式及其严格规则。 - async-tasks.md — 阻塞为何会影响所有人、
@reactive.extended_task模式及其严格规则、线程与进程池。 - data-loading.md — 进程作用域加载(Core 和 Express)、polars/parquet/DuckDB 惰性加载、数据库、下载和上传。
- rendering-ui.md — 通过标签页暂停输出、
@render.ui的替代方案、图表/表格渲染开销、感知性能。